From 28707bcbe1f6fa220f1c2c023e89c60618700039 Mon Sep 17 00:00:00 2001 From: netrunner714 Date: Tue, 10 Nov 2026 18:10:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=B4=20status,=20=D0=BC=D0=B5=D1=82=D0=B0=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20Next.js=20?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D1=8F=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BF=D1=83=D0=BB=D1=8B=20(#9,=20#28,=20#29,=20?= =?UTF-8?q?#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единый дашборд pegas status по меткам контейнеров и метаданным маршрутов, аудит allowedDevOrigins в pegas check, разделяемые пулы PostgreSQL/Redis с scope shared. Версия 3.3.0. --- CHANGELOG.md | 20 + README.md | 5 +- docs/README.md | 1 + docs/explanation/architecture.md | 11 +- docs/how-to/local-https.md | 24 + docs/how-to/nextjs.md | 130 +++ .../principles/declarative-runtime.md | 2 +- docs/reference/cli.md | 8 +- llms.txt | 8 +- package.json | 2 +- schemas/pegas.json | 49 + scripts/gen-docs.ts | 2 +- src/commands/check.test.ts | 166 ++++ src/commands/check.ts | 53 +- src/commands/connect.ts | 10 + src/commands/deploy.ts | 73 +- src/commands/down.ts | 17 + src/commands/status.dashboard.test.ts | 354 +++++++ src/commands/status.test.ts | 54 +- src/commands/status.ts | 797 ++++++++++++---- src/contract.ts | 70 ++ src/contract.v3.test.ts | 99 ++ src/generator.ts | 10 + src/help.ts | 14 +- src/index.ts | 1 + src/nextjs-audit.test.ts | 861 +++++++++++++++++ src/nextjs-audit.ts | 868 ++++++++++++++++++ src/route-merge.ts | 24 +- src/route-meta.test.ts | 139 +++ src/route-meta.ts | 144 +++ src/runtime-compose.ts | 20 +- src/shared-infra.test.ts | 561 +++++++++++ src/shared-infra.ts | 616 +++++++++++++ src/table.test.ts | 44 + src/table.ts | 104 ++- src/types.ts | 49 + 36 files changed, 5204 insertions(+), 206 deletions(-) create mode 100644 docs/how-to/nextjs.md create mode 100644 src/commands/status.dashboard.test.ts create mode 100644 src/nextjs-audit.test.ts create mode 100644 src/nextjs-audit.ts create mode 100644 src/route-meta.test.ts create mode 100644 src/route-meta.ts create mode 100644 src/shared-infra.test.ts create mode 100644 src/shared-infra.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 51bbb8d..fb87a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версионирование — [SemVer](https://semver.org/lang/ru/). +## [3.3.0] — 2026-11-10 + +### Added + +- **Единый дашборд `pegas status`** ([#9](https://github.com/netrunner714/pegas/issues/9), [#30](https://github.com/netrunner714/pegas/issues/30)) — одна таблица по всем проектам и сценариям машины: PROJECT / SERVICE / ENV / DRIVER / ENDPOINT / TARGET / GATEWAY / HEALTH / MEM / UP: + - контейнеры сопоставляются с маршрутами по меткам Pegas (`pegas.project`, `pegas.service`, `pegas.scenario`), а не по совпадению имён: одинаковые имена сервисов в разных проектах и сценарии с дефисом (`pre-prod`) разбираются корректно; алиасная ветка дополнительно сверяет имя проекта; + - проект текущего каталога помечается `★` (флаг `--cwd` проброшен в команду); здоровье host-сервисов проверяется реальным TCP-пробником, для docker — состоянием контейнера; проблемы собираются в итоговый блок `⚠️ Обнаружено проблем: N` с подсказкой по каждому сервису; + - JSON сохраняет обратную совместимость: рядом с новым `services[]` остаются legacy `projects[]` и добавляется `issues[]`. +- **Метаданные маршрута** ([#30](https://github.com/netrunner714/pegas/issues/30)) — перед блоком домена пишется комментарий `# pegas:project=… | service=… | env=… | driver=… | owner=… | port=…`: `connect` ставит `driver: bun, owner: connect`, `deploy` — `driver: docker, owner: deploy`. Merge и strip блоков (#24) сохраняют метаданные, а legacy-файлы без них по-прежнему разбираются выводом сервиса из домена/upstream и сценария из имени файла. +- **Аудит Next.js `allowedDevOrigins`** ([#28](https://github.com/netrunner714/pegas/issues/28)) — `pegas check` предупреждает, если dev-домены проекта на шлюзе (`*.<проект>.localhost`) не покрыты `allowedDevOrigins`, и печатает готовый рецепт. Рецепт — `['<проект>.localhost', '**.<проект>.localhost']`, а не `['*.localhost']` из текста issue: в Next.js `*` совпадает ровно с одной DNS-меткой, поэтому `*.localhost` не покрывает `<сервис>.<проект>.localhost`, а само наличие опции переводит проверку из warn в block и дало бы жёсткий 403 (проверено по исходникам Next.js 15.3.0). Добавлено руководство `docs/how-to/nextjs.md`. +- **Разделяемые инфраструктурные пулы** ([#29](https://github.com/netrunner714/pegas/issues/29)) — поля контракта `scope: "shared"` и `shared: { name, secret? }` для PostgreSQL/Redis-подобных образов: + - один инстанс пула на профиль образа и сценарий (`pegas-shared-postgres-18-development`) обслуживает несколько проектов: изоляция — отдельная база и роль (postgres) или префикс ключей (redis); имя арендатора объявляется в контракте (`shared.name`), а не выводится из имени проекта, поэтому переименование проекта не создаёт вторую базу; + - пулы не несут метку `pegas.project`, поэтому `pegas down` одного проекта не может удалить общую базу; `down` только снимает аренду проекта и останавливает опустевший инстанс, данные арендаторов не удаляются; + - `deploy` поднимает пул и готовит арендатора до `compose up`; shared-сервисы не попадают в compose-манифест, а `pegas://<сервис>` и `PEGAS_CONTEXT` указывают на алиас пула. Пароль суперпользователя — файл с правами 0600, монтируемый как `POSTGRES_PASSWORD_FILE`; пароль арендатора идёт в `psql` через stdin — секреты не попадают в argv; + - валидатор контракта и JSON-схема: shared запрещён с `bun`, `build`, `volumes` и `dependsOn`, требует поддерживаемый образ и `port`, а `shared.secret` обязан быть объявлен в `services..secrets`. + +### Changed + +- **Выравнивание таблиц** — ширина строк считается по кодпоинтам: эмодзи с Emoji_Presentation, модификаторы VS15/VS16 и широкие CJK-символы больше не ломают колонки дашборда (`★`, `⚠️`, `➖` и подобные). + ## [3.2.0] — 2026-11-09 ### Added diff --git a/README.md b/README.md index 1659ed3..5ee7ddd 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,8 @@ bunx @netrunner714/pegas init `scenarios` — открытый список: имён может быть сколько угодно, имена произвольны (`^[a-z0-9-]+$`); три выше — конвенция, не ограничение. Каждая сценарная карта (runtime, port, domains…) обязана содержать ровно эти ключи. -Проверка: `pegas check` — рид-онли аудит (контракт, паритет секретов, git-гигиена, свежесть артефактов). +Проверка: `pegas check` — рид-онли аудит (контракт, паритет секретов, git-гигиена, свежесть артефактов, +dev-конфигурация Next.js на доменах `*.localhost`). ### 3. Локальная разработка с HTTPS @@ -199,7 +200,7 @@ const key = secret("DATABASE_URL"); // секрет с понятной о | Раздел | Содержание | |---|---| | [Быстрый старт](docs/tutorials/quickstart.md) | Пошагово от init до деплоя | -| [Руководства](docs/how-to/) | [init](docs/how-to/init.md), [локальный HTTPS](docs/how-to/local-https.md) | +| [Руководства](docs/how-to/) | [init](docs/how-to/init.md), [локальный HTTPS](docs/how-to/local-https.md), [Next.js](docs/how-to/nextjs.md) | | [Справочники](docs/reference/) | [CLI](docs/reference/cli.md) (генерируется), [контракт](docs/reference/contract.md) | | [Архитектура](docs/explanation/architecture.md) | Устройство и обоснование решений | | [Контекст для агентов](llms.txt) | Полный контекст для ИИ-ассистентов | diff --git a/docs/README.md b/docs/README.md index f64d400..6f60bde 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ * [Инициализация проекта](./how-to/init.md) — `pegas init`: артефакты для агентов, git-гигиена, канон docs/pegas.md. * [Локальная разработка с HTTPS](./how-to/local-https.md) — `pegas connect`, доверенный CA, единый шлюз, `pegas disconnect`. +* [Next.js и домены шлюза](./how-to/nextjs.md) — `allowedDevOrigins`: HMR и dev-ресурсы Next.js на доменах `*.localhost`. ## Справочники diff --git a/docs/explanation/architecture.md b/docs/explanation/architecture.md index 6db9e38..3312033 100644 --- a/docs/explanation/architecture.md +++ b/docs/explanation/architecture.md @@ -109,10 +109,19 @@ upstream'ы маршрутов шлюза и значений `pegas://` — о вектор повышения привилегий исключён. - Дефолтные security-заголовки: HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy`. -- Локальный dev — user-space: без sudo, Caddy из `~/.pegas/bin`. +- Локальный dev — user-space: Caddy из `~/.pegas/bin`, порты `:80/:443` + привязываются без sudo (macOS/Windows; на Linux — при + `CAP_NET_BIND_SERVICE` или `net.ipv4.ip_unprivileged_port_start=0`). - Ротация логов контейнеров (`max-size: 50m`, `max-file: 3`) — диск не переполняется. +## Единственный Caddy + +В Pegas нет отдельного «gateway-движка»: `pegas-gateway` — это тот же +user-space Caddy из `~/.pegas/bin`, запущенный как контейнер. Один бинарник, +одна модель конфигурации (JSON), один способ работы с сертификатами — и локально +(`pegas serve`/`pegas up`), и внутри шлюза. + ## Ноль зависимостей CLI собран целиком на стандартных модулях Node (`node:fs`, diff --git a/docs/how-to/local-https.md b/docs/how-to/local-https.md index fd5d284..c055f58 100644 --- a/docs/how-to/local-https.md +++ b/docs/how-to/local-https.md @@ -10,6 +10,30 @@ Pegas решает эту задачу с помощью **единого общего Docker Gateway** (`pegas-gateway`) и прозрачной привязки хостовых портов (`pegas connect`). +### Чем это НЕ является + +* **Не туннель наружу.** `*.localhost` резолвится только на вашей машине: ни + публичного адреса, ни внешнего трафика. Если нужен URL из интернета + (`https://xyz.ngrok.io`, `*.ts.net`) — это задача ngrok / cloudflared / + Tailscale Funnel, и Pegas их не заменяет (но и не мешает). +* **Не прокси внутри проекта.** Ничего не запускается per-project: один gateway + на машину обслуживает все домены. +* **Не правка dev-скриптов.** Ни `--https` флагов, ни конфигов vite/webpack: + приложение слушает обычный HTTP, TLS терминируется снаружи. +* **Не root.** Caddy user-space, порты `:80/:443` без sudo (см. + `docs/explanation/architecture.md`). + +### Сравнение с альтернативами + +| Подход | Доверенный сертификат | URL из интернета | Налог на проект | +|---|---|---|---| +| `vite --https`, `node --experimental-https` | самоподписанный → предупреждение браузера | нет | флаг/конфиг в каждом проекте | +| mkcert | да (локальный CA) | нет | сертификаты вручную на каждый домен, прокси поднимать самому | +| Traefik + Docker socket | да | нет | `/var/run/docker.sock` внутри прокси = root-эквивалент | +| nginx + свои конфиги | вручную | нет | шаблон на сайт, reload при изменениях | +| ngrok / cloudflared / tailscale funnel | да (публичный CA) | **да** | внешний сервис, аккаунт, трафик через чужую сеть | +| **Pegas** (`up` / `serve` / `connect`) | да (локальный Root CA, один раз) | нет — по дизайну | ноль: один бинарник, один общий gateway | + --- ## 1. Установка доверенного Root CA diff --git a/docs/how-to/nextjs.md b/docs/how-to/nextjs.md new file mode 100644 index 0000000..beb776a --- /dev/null +++ b/docs/how-to/nextjs.md @@ -0,0 +1,130 @@ +# Next.js и домены `*.localhost` шлюза Pegas (`allowedDevOrigins`) + +> How-to: настроить dev-сервер Next.js так, чтобы HMR и dev-ресурсы работали на +> доменах шлюза Pegas (`.localhost`, `..localhost`). + +## Проблема + +Pegas публикует локальные сервисы на доменах зоны `.localhost` через общий +шлюз (Caddy): `pegas connect` и `pegas deploy -s development` строят маршруты из +поля `services..domains` сценария `development`. + +Next.js, начиная с версии 15.3 (и в backport-ветке 14.2.30), по умолчанию +защищает собственные dev-ресурсы (`/_next/*`, `/__nextjs*`) от обращений с +посторонних origin. Хост из заголовка `Origin` (для запросов без него — из +`Referer`) сравнивается со списком разрешённых; домен шлюза в этот список не +входит. Dev-сервер печатает предупреждение и/или отвечает `403` на HMR-сокете, +что выглядит как ошибка CORS на стороне шлюза: + +```text +⚠ Blocked cross-origin request to Next.js dev resource /_next/webpack-hmr + from "site.my-app.localhost". +Cross-origin access to Next.js dev resources is blocked by default for safety. +To allow this host in development, add it to "allowedDevOrigins" in next.config.js +``` + +Причина не в маршрутизации Pegas, а в настройке самого Next.js: домен шлюза +нужно разрешить опцией `allowedDevOrigins`. + +## Решение + +Опция `allowedDevOrigins` — массив строк в конфигурации Next.js. Значения +сопоставляются только с hostname запроса: схему, порт и путь указывать не нужно. +Поддерживаются шаблоны: `*` заменяет ровно одну метку hostname, `**` — одну и +более, но только в начале шаблона. + +Рекомендуемый набор значений для проекта Pegas — точное имя зоны и рекурсивный +шаблон на каждый `.localhost`: + +```ts +// next.config.ts +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + allowedDevOrigins: ["my-app.localhost", "**.my-app.localhost"], +}; + +export default nextConfig; +``` + +```js +// next.config.js +module.exports = { + allowedDevOrigins: ["my-app.localhost", "**.my-app.localhost"], +}; +``` + +```js +// next.config.mjs +export default { + allowedDevOrigins: ["my-app.localhost", "**.my-app.localhost"], +}; +``` + +После изменения конфигурации dev-сервер Next.js перезапускают. + +### Почему `*.localhost` недостаточно + +В сообщении Next.js и в его документации встречается универсальный на первый взгляд +шаблон `['*.localhost']`. Он соответствует ровно одной метке перед `.localhost`, +то есть покрывает `.localhost`, но не покрывает +`..localhost`. Проект Pegas, у которого есть и корневой домен, +и домены сервисов, такой шаблон покрывает лишь частично. + +Есть следствие, которое важно учитывать. В Next.js 14.2.30–16.1 режим реакции +зависит от того, задана ли опция вообще: пока `allowedDevOrigins` отсутствует, +dev-сервер ограничивается предупреждением и пропускает запрос; как только опция +появляется, непокрытый origin получает жёсткий ответ `403`. Начиная с 16.2 +запрос с непокрытого origin блокируется всегда. Поэтому запись +`allowedDevOrigins: ['*.localhost']` в проекте с доменами вида +`..localhost` превращает предупреждение в блокировку: +HMR перестаёт работать окончательно. + +Точное имя зоны плюс рекурсивный шаблон (`"my-app.localhost", "**.my-app.localhost"`) +покрывают и корень зоны, и поддомены любого уровня. Этот набор работает на всех +версиях Next.js, где опция существует, включая ветку 14.2 (в ней шаблоны +`*.localhost` и `**.localhost` не сопоставляются вовсе, а точное имя и +`**..localhost` — работают). + +В Next.js 16.3+ встроенный список разрешённых origin содержит `**.localhost` +вместо `*.localhost`, поэтому домены шлюза любой глубины разрешены без +дополнительной настройки. + +## Поведение `pegas check` + +`pegas check` инспектирует проект и выдаёт предупреждение (не ошибку), если: + +- в `package.json` объявлен Next.js версии, поддерживающей `allowedDevOrigins` + (15.3.0+ или 14.2.30+), и +- в сценарии `development` контракта есть домены зоны `.localhost`, и +- файл `next.config.{js,mjs,ts,mts,cjs}` отсутствует, либо в нём нет ключа + `allowedDevOrigins`, либо его значение не покрывает dev-домены проекта. + +Предупреждение содержит готовый список значений и ссылку на этот гайд. Проверка +учитывает встроенный в Next.js список разрешённых origin: `localhost` разрешён +всегда, `*.localhost` — в версиях до 16.2, `**.localhost` — с 16.3. Поэтому на +Next.js 16.3+ предупреждение о доменах `.localhost` не выдаётся: они покрыты +встроенным списком. + +Если значение `allowedDevOrigins` вычисляется динамически (переменная, вызов +функции, spread, интерполяция), `pegas check` не может разобрать его +текстовым анализом. Вместо ложного предупреждения команда выдаёт подсказку +проверить покрытие доменов вручную. + +Аудит работает только с корнем проекта. Монорепо, где приложение Next.js лежит +в подкаталоге (например, `apps/web`), не инспектируется: предупреждение в этом +случае не выдаётся. + +## Версии Next.js без `allowedDevOrigins` + +Опция `allowedDevOrigins` появилась в Next.js 15.3.0 и в ветке 14.2 начиная с +14.2.30. На более ранних версиях (15.0–15.2, 14.2.29 и ниже) защита dev-ресурсов +не реализована, опция отсутствует, и `pegas check` не выдаёт предупреждение о +ней. Наличие ключа `allowedDevOrigins` в конфигурации такой версии Next.js +отмечает как неизвестную опцию. + +## Связанные материалы + +* [Локальная разработка с HTTPS](./local-https.md) — `pegas connect`, доверенный CA, домены `*.localhost`. +* [Контракт pegas.json](../reference/contract.md) — поле `services..domains` и сценарные карты. +* Документация Next.js: `allowedDevOrigins` — . diff --git a/docs/internal/principles/declarative-runtime.md b/docs/internal/principles/declarative-runtime.md index 373099e..7fd5ac0 100644 --- a/docs/internal/principles/declarative-runtime.md +++ b/docs/internal/principles/declarative-runtime.md @@ -87,7 +87,7 @@ Pegas перестаёт быть обёрткой над dev-процессом ## 4. Модель запуска - Общая сеть `pegas-network` (external) — уже существует, переиспользуется. -- Общий Caddy Gateway — один контейнер на машину. Каждый checkout и environment (`development`, `stage`, `production`) получает отдельное имя Compose-проекта; контейнеры помечаются `pegas.project` и `pegas.environment`. DNS-алиас сервиса внутри общей сети имеет вид `--<сервис>:<порт>`, поэтому dev/stage/prod одного checkout не конкурируют за один alias. +- Общий Caddy Gateway — один контейнер на машину. Каждый checkout и environment (`development`, `stage`, `production`) получает отдельное имя Compose-проекта; контейнеры помечаются `pegas.project` и `pegas.environment`. DNS-алиас сервиса внутри общей сети имеет вид `--<сервис>:<порт>`, поэтому development/stage/production одного checkout не конкурируют за один alias. - Dev-приложения: контейнер + bind mount исходников + команда дев-сервера + изолированный том зависимостей. Несколько проектов могут слушать одинаковый порт — сетевые namespace разные. - Манифест подъёма (compose-формат) генерируется из контракта, применяется через `docker compose -f -` (stdin) или временный файл, **и удаляется** — D3. - Идемпотентность: hash манифеста пишется в labels. `pegas dev`: hash совпал и контейнеры живы → ничего не пересоздаём, сразу подключаемся к логам. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index df7bf34..a311b1d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -179,7 +179,13 @@ pegas check --json ### `pegas status [options]` -Проверяет здоровье шлюза и активные маршруты проекта +Единый дашборд: здоровье шлюза плюс все сервисы Pegas со здоровьем реальных процессов + +Флаги: + +```text +--cwd Целевой каталог проекта +``` Примеры: diff --git a/llms.txt b/llms.txt index 5429140..a21e9c3 100644 --- a/llms.txt +++ b/llms.txt @@ -32,15 +32,15 @@ - `npx @netrunner714/pegas update [options]` (--latest, --cwd) — Update @netrunner714/pegas to latest version. - `npx pegas connect` (--cwd, --dry-run) — Expose development services via shared gateway (routes host services through host.docker.internal). - `npx pegas disconnect` (--cwd) — Remove development route file from the gateway and stop the gateway if unused. -- `npx pegas logs -f` (-e, -f, -n, --cwd) — Stream or view container logs for any environment (development, stage, production). -- `npx pegas logs production` (-e, -f, -n, --cwd) — Stream or view container logs for any environment (development, stage, production). -- `npx pegas logs api -f` (-e, -f, -n, --cwd) — Stream or view container logs for any environment (development, stage, production). +- `npx pegas logs -f` (-e, -f, -n, --cwd) — Stream or view container logs for development, stage, and production environments. +- `npx pegas logs production` (-e, -f, -n, --cwd) — Stream or view container logs for development, stage, and production environments. +- `npx pegas logs api -f` (-e, -f, -n, --cwd) — Stream or view container logs for development, stage, and production environments. - `npx pegas deploy -s [options]` (-s, --cwd, --skip-dns-check, --strict-dns, --skip-up, --dry-run) — Deploy a scenario from pegas.json: network, shared Gateway, containers, routes. - `npx pegas down [options]` (--cwd, --env) — Remove project containers and Caddy routes (all environments, or one with --env). - `npx pegas ca install` (--dry-run) — Manage local Root CA for trusted dev HTTPS (install/remove). - `npx pegas ca remove` (--dry-run) — Manage local Root CA for trusted dev HTTPS (install/remove). - `npx pegas check [options]` (--cwd) — Read-only audit: validate the v3 contract, secrets parity, and .gitignore hygiene (no --fix, never mutates). -- `npx pegas status [options]` — Check gateway health and active project routes. +- `npx pegas status [options]` (--cwd) — Unified dashboard: gateway health plus every Pegas service with real process health. - `npx pegas llms [options]` — Print machine-readable LLM context. ## Global Options diff --git a/package.json b/package.json index 2b88b69..0d33c92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@netrunner714/pegas", - "version": "3.2.0", + "version": "3.3.0", "description": "Bun-native, agent-first zero-config L7 reverse proxy orchestrator for Docker", "main": "dist/index.js", "type": "module", diff --git a/schemas/pegas.json b/schemas/pegas.json index 22c8a6e..4199aa9 100644 --- a/schemas/pegas.json +++ b/schemas/pegas.json @@ -437,6 +437,30 @@ } ] }, + "sharedTenant": { + "description": "Параметры аренды в разделяемом пуле (issue #29): имя базы и роли (postgres) или префикс ключей (redis); secret — имя ключа из .env, объявленного также в services..secrets.", + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$", + "examples": [ + "alpha_db" + ] + }, + "secret": { + "type": "string", + "pattern": "^[A-Z0-9_]+$", + "examples": [ + "DATABASE_PASSWORD" + ] + } + } + }, "service": { "type": "object", "required": [ @@ -624,6 +648,31 @@ "SESSION_SECRET" ] ] + }, + "scope": { + "type": "object", + "description": "Область жизненного цикла по сценариям (issue #29): isolated — собственный контейнер проекта (по умолчанию); shared — аренда в общем инфраструктурном пуле Pegas. Shared допустим только для поддерживаемых образов (postgres, redis и производные), без build, volumes и dependsOn.", + "additionalProperties": { + "type": "string", + "enum": [ + "isolated", + "shared" + ] + } + }, + "shared": { + "type": "object", + "description": "Параметры аренды в разделяемом пуле по сценариям (issue #29); объект обязателен в сценариях со scope: \"shared\", в остальных — null.", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/$defs/sharedTenant" + }, + { + "type": "null" + } + ] + } } }, "examples": [ diff --git a/scripts/gen-docs.ts b/scripts/gen-docs.ts index c58399c..dd14b5f 100644 --- a/scripts/gen-docs.ts +++ b/scripts/gen-docs.ts @@ -34,7 +34,7 @@ const RU_COMMANDS: Record = { down: "Удаляет контейнеры проекта и его маршруты из Caddy", ca: "Управляет локальным Root CA для доверенного dev-HTTPS (install/remove)", check: "Read-only аудит v3-контракта: валидатор, паритет секретов, гигиена .gitignore. Ничего не меняет", - status: "Проверяет здоровье шлюза и активные маршруты проекта", + status: "Единый дашборд: здоровье шлюза плюс все сервисы Pegas со здоровьем реальных процессов", llms: "Выводит машиночитаемый контекст для LLM-агентов", }; diff --git a/src/commands/check.test.ts b/src/commands/check.test.ts index aa423e5..4195b08 100644 --- a/src/commands/check.test.ts +++ b/src/commands/check.test.ts @@ -272,3 +272,169 @@ describe("check: Caddyfile, копируемый в Docker-образ — не expect(result.warnings.some((w) => w.includes("Caddyfile") && w.includes("game_client/Caddyfile"))).toBe(true); }); }); + +describe("check: Next.js allowedDevOrigins (issue #28)", () => { + /** + * Валидный репозиторий с dev-доменами зоны .localhost и проектом на Next.js. + * Секреты не объявлены: аудит проверяет только предупреждение о Next.js, + * поэтому .env не нужен и ошибки секретов не шумят. + */ + async function nextjsRepo(opts: { + domains: string[]; + configName?: string; + configText?: string; + next?: string; + }): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pegas-chk-next-")); + const web: Record = { + runtime: { development: { driver: "bun" } }, + build: { development: null }, + port: { development: 3000 }, + }; + if (opts.domains.length > 0) web.domains = { development: opts.domains }; + await fs.writeFile(path.join(dir, "pegas.json"), JSON.stringify({ + projectName: "next-app", + scenarios: ["development"], + gateway: { development: { httpPort: 80, httpsPort: 443 } }, + services: { web }, + }, null, 2)); + await fs.writeFile(path.join(dir, ".gitignore"), ".pegas\n.env*\n!.env.example\n"); + await fs.writeFile(path.join(dir, ".env.example"), ""); + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ + name: "next-app", + dependencies: { next: opts.next ?? "15.5.0", react: "19.0.0" }, + })); + if (opts.configName) await fs.writeFile(path.join(dir, opts.configName), opts.configText ?? ""); + return dir; + } + + it("непокрытые dev-домены → warning и nextjsIssues, valid остаётся true", async () => { + const dir = await nextjsRepo({ + domains: ["next-app.localhost", "api.next-app.localhost"], + configName: "next.config.ts", + configText: "export default { reactStrictMode: true };", + }); + try { + const result = await auditRepository(dir); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + expect(result.nextjsIssues.length).toBe(1); + expect(result.warnings).toEqual(expect.arrayContaining(result.nextjsIssues)); + expect(result.nextjsIssues[0]).toContain("allowedDevOrigins"); + expect(result.nextjsIssues[0]).toContain("api.next-app.localhost"); + expect(result.nextjsAudit?.status).toBe("missing-option"); + expect(result.hasWarnings).toBe(true); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("покрытие настроено → ни warning, ни nextjsIssues", async () => { + const dir = await nextjsRepo({ + domains: ["next-app.localhost", "api.next-app.localhost"], + configName: "next.config.ts", + configText: "export default { allowedDevOrigins: ['next-app.localhost', '**.next-app.localhost'] };", + }); + try { + const result = await auditRepository(dir); + expect(result.nextjsIssues).toEqual([]); + expect(result.nextjsAudit?.status).toBe("ok"); + expect(result.warnings.some((w) => w.includes("allowedDevOrigins"))).toBe(false); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("без Next.js в package.json → аудит молчит, nextjsIssues пустой массив", async () => { + const dir = await nextjsRepo({ + domains: ["next-app.localhost"], + configName: "next.config.ts", + configText: "export default {};", + }); + try { + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ name: "plain", dependencies: { express: "^5" } })); + const result = await auditRepository(dir); + expect(result.nextjsIssues).toEqual([]); + expect(result.nextjsAudit?.nextjsDetected).toBe(false); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("dev-домены вне зоны .localhost → предупреждение о Next.js не выдаётся", async () => { + const dir = await nextjsRepo({ + domains: ["dev.example.com"], + configName: "next.config.ts", + configText: "export default {};", + }); + try { + const result = await auditRepository(dir); + expect(result.nextjsIssues).toEqual([]); + expect(result.nextjsAudit?.status).toBe("not-applicable"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("битый package.json не роняет аудит репозитория", async () => { + const dir = await nextjsRepo({ domains: ["next-app.localhost"] }); + try { + await fs.writeFile(path.join(dir, "package.json"), "{ не json"); + const result = await auditRepository(dir); + expect(result.valid).toBe(true); + expect(result.nextjsIssues).toEqual([]); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("handleCheck: текстовый вывод показывает блок предупреждения Next.js", async () => { + const dir = await nextjsRepo({ + domains: ["next-app.localhost", "api.next-app.localhost"], + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['*.localhost'] };", + }); + try { + const logs: string[] = []; + const spy = spyOn(console, "log").mockImplementation((msg) => logs.push(String(msg))); + try { + await handleCheck({ cwd: dir, json: false, noExit: true }); + } finally { + spy.mockRestore(); + } + const out = logs.join("\n"); + expect(out).toContain("Next.js"); + expect(out).toContain("allowedDevOrigins"); + expect(out).toContain("**.next-app.localhost"); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("handleCheck --json: nextjsAudit и nextjsIssues сериализуются", async () => { + const dir = await nextjsRepo({ + domains: ["api.next-app.localhost"], + configName: "next.config.mjs", + configText: "export default { allowedDevOrigins: originsFromEnv };", + }); + try { + const logs: string[] = []; + const spy = spyOn(console, "log").mockImplementation((msg) => logs.push(String(msg))); + try { + await handleCheck({ cwd: dir, json: true, noExit: true }); + } finally { + spy.mockRestore(); + } + const parsed = JSON.parse(logs[0]); + expect(parsed.valid).toBe(true); + expect(Array.isArray(parsed.nextjsIssues)).toBe(true); + expect(parsed.nextjsAudit.status).toBe("indeterminate"); + expect(parsed.nextjsAudit.warning).toBeNull(); + expect(parsed.nextjsAudit.hint).toContain("вычисляется динамически"); + // Подсказка (не ложное обвинение) тоже попадает в warnings и nextjsIssues. + expect(parsed.nextjsIssues.length).toBe(1); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/check.ts b/src/commands/check.ts index 2c080b9..f55e76e 100644 --- a/src/commands/check.ts +++ b/src/commands/check.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { validatePegasContract, type ContractValidationIssue } from "../contract.js"; +import { getEnvironmentDomains, validatePegasContract, type ContractValidationIssue } from "../contract.js"; +import { auditNextjsDevOrigins, type NextjsAuditFinding } from "../nextjs-audit.js"; import { verifySecrets, formatSecretsDiagnostic, isIgnoredByGit } from "../secrets.js"; import type { CheckOptions, PegasContract } from "../types.js"; @@ -20,10 +21,35 @@ export interface RepoAuditResult { gitignoreIssues: string[]; /** Свежесть AGENTS.md-блока и docs/pegas.md против канона установленного пегаса (детектор, не мутатор). */ artifactIssues: string[]; + /** + * Dev-конфигурация Next.js: покрытие доменов шлюза в `allowedDevOrigins` + * (issue #28). Предупреждения, не ошибки: на валидность контракта не влияют. + */ + nextjsIssues: string[]; + /** Детали аудита Next.js для машиночитаемого вывода (--json); null, если аудит не сработал. */ + nextjsAudit?: NextjsAuditFinding | null; errors: string[]; warnings: string[]; } +/** + * Dev-домены сценария `development` из контракта — все сервисы, без дублей. + * Пустой массив, если контракта нет: аудитор Next.js трактует его как + * «проект не публикует dev-сервисы на доменах шлюза» и молчит. + */ +function collectDevelopmentDomains(contract: PegasContract | undefined): string[] { + const out: string[] = []; + if (!contract || typeof contract !== "object") return out; + const services = contract.services && typeof contract.services === "object" ? contract.services : {}; + for (const svc of Object.values(services)) { + if (!svc || typeof svc !== "object") continue; + for (const domain of getEnvironmentDomains(svc, "development")) { + if (typeof domain === "string" && domain && !out.includes(domain)) out.push(domain); + } + } + return out; +} + export async function auditRepository( cwd = process.cwd(), options?: { envFile?: string } @@ -149,6 +175,22 @@ export async function auditRepository( } } + // 5. Next.js: allowedDevOrigins против dev-доменов шлюза (issue #28). + // Предупреждение, не ошибка: контракт от этого не становится невалидным. + // Без контракта dev-домены неизвестны — передаём undefined, и аудитор + // проверяет канонические формы .localhost / ..localhost. + const nextjsIssues: string[] = []; + let nextjsAudit: NextjsAuditFinding | null = null; + try { + const devDomains = contract ? collectDevelopmentDomains(contract) : undefined; + nextjsAudit = await auditNextjsDevOrigins(projectRoot, { devDomains }); + if (nextjsAudit.warning) nextjsIssues.push(nextjsAudit.warning); + else if (nextjsAudit.hint) nextjsIssues.push(nextjsAudit.hint); + warnings.push(...nextjsIssues); + } catch { + // аудит Next.js не обязан ронять check: пропускаем тихо + } + const hasErrors = errors.length > 0; const hasWarnings = warnings.length > 0; const valid = !hasErrors; @@ -164,6 +206,8 @@ export async function auditRepository( secretsCheck, gitignoreIssues, artifactIssues, + nextjsIssues, + nextjsAudit, errors, warnings, }; @@ -218,6 +262,13 @@ export async function handleCheck(options: CheckOptions = {}): Promise { console.log(` - ${ai}`); } } + // 5. Next.js: allowedDevOrigins против доменов шлюза (issue #28) + if (result.nextjsIssues.length > 0) { + console.log(`\n ⚠️ Next.js (dev-ресурсы на доменах шлюза):`); + for (const ni of result.nextjsIssues) { + console.log(` - ${ni}`); + } + } console.log(""); if (result.valid && !result.hasWarnings) { diff --git a/src/commands/connect.ts b/src/commands/connect.ts index e6b330e..49ab991 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -150,6 +150,16 @@ export async function handleConnect(options: ConnectOptions = {}): Promise upstream, cors: isCorsEnabled ? true : false, corsOrigins: allProjectDomains, + // issue #30: метаданные маршрута. connect владеет только host-сервисами, + // поэтому driver всегда "bun", а owner — "connect". + meta: { + project: projectName, + service: serviceName, + env: "development", + driver: "bun", + owner: "connect", + port: servicePort, + }, }), ); registeredUrls.push({ diff --git a/src/commands/deploy.ts b/src/commands/deploy.ts index 496b431..c01feeb 100644 --- a/src/commands/deploy.ts +++ b/src/commands/deploy.ts @@ -11,7 +11,16 @@ import { runRuntimeCompose } from "../runtime.js"; import { createRuntimeCompose } from "../runtime-compose.js"; import { ensureGatewayContainer, stopGatewayIfUnused } from "../docker.js"; import { withRuntimeLock } from "../runtime-lock.js"; -import { verifySecrets } from "../secrets.js"; +import { parseEnvContent, verifySecrets } from "../secrets.js"; +import { + classifySharedImage, + createSharedInfraDeps, + ensureSharedPool, + provisionSharedTenant, + sharedPoolKey, + waitForSharedPoolReady, + type SharedInfraDeps, +} from "../shared-infra.js"; import { mergeRouteFile, writeRouteFileAtomically, stripOwnerBlocks } from "../route-merge.js"; export interface DeployOptions { @@ -160,6 +169,55 @@ export function resolveScopedUpstream(upstream: string, projectName: string): st return `${projectName}-${host}:${port}`; } +/** + * Разделяемые пулы сценария (issue #29): поднимает инстансы и готовит арендаторов + * до compose up, чтобы pegas:// в манифесте уже указывал на живой пул. + * Идемпотентно; секреты читаются из .env в память и никуда не записываются. + */ +async function ensureSharedPoolsForDeploy(args: { + contract: PegasContract; + projectRoot: string; + projectName: string; + runtimeId: string; + env: string; + deps?: SharedInfraDeps; +}): Promise { + const { contract, projectRoot, projectName, runtimeId, env } = args; + const deps = args.deps ?? createSharedInfraDeps(); + const sharedServices = Object.entries(contract.services ?? {}).filter( + ([, svc]) => svc.scope?.[env] === "shared" + ); + if (sharedServices.length === 0) return; + let envValues = new Map(); + try { + envValues = parseEnvContent(await fs.readFile(path.join(projectRoot, ".env"), "utf-8")); + } catch {} + for (const [serviceName, svc] of sharedServices) { + const tenant = svc.shared?.[env]; + if (!tenant) throw new Error(`services.${serviceName}: scope: "shared" требует shared в сценарии "${env}".`); + const profile = classifySharedImage(svc.image?.[env]); + if (!profile) throw new Error(`services.${serviceName}: образ не является поддерживаемой разделяемой инфраструктурой.`); + const port = svc.port?.[env]; + const ensured = await ensureSharedPool(deps, { + profile, + scenario: env, + publishHostPort: env === "development", + ...(typeof port === "number" ? { hostPort: port, port } : {}), + }); + if (ensured.created) await waitForSharedPoolReady(deps, profile, env); + const secretValue = tenant.secret ? envValues.get(tenant.secret) : undefined; + await provisionSharedTenant( + deps, + sharedPoolKey(profile, env), + profile, + env, + { project: runtimeId, projectName, scenario: env, service: serviceName, name: tenant.name }, + tenant, + secretValue + ); + } +} + async function handleDeployUnlocked(options: DeployOptions = {}): Promise { const isJson = options.json ?? false; const cwd = options.cwd || process.cwd(); @@ -428,6 +486,9 @@ async function handleDeployUnlocked(options: DeployOptions = {}): Promise const gatewayResult = await ensureGatewayContainer(gatewayDir); if (!gatewayResult.success) throw new Error(gatewayResult.stderr || "Failed to ensure shared Gateway."); gatewayPrepared = true; + // issue #29: разделяемые пулы живут вне compose-проекта и должны быть + // готовы до up — потребители резолвят pegas:// на алиас пула. + await ensureSharedPoolsForDeploy({ contract, projectRoot, projectName, runtimeId, env }); // v3: манифест содержит только driver:"docker"-сервисы сценария; host-сервисы не поднимаем await runRuntimeCompose(contract, projectRoot, runtimeId, [ "up", "-d", "--remove-orphans", @@ -526,6 +587,16 @@ async function handleDeployUnlocked(options: DeployOptions = {}): Promise upstream: scopedUpstream, cors: isCorsEnabled ? true : false, corsOrigins: allProjectDomains, + // issue #30: метаданные маршрута — status определяет сервис/сценарий/драйвер + // по ним, а не эвристикой по имени файла и upstream. + meta: { + project: projectName, + service: serviceName, + env, + driver: "docker", + owner: "deploy", + ...(typeof svcPort === "number" ? { port: svcPort } : {}), + }, })); deployedRoutes.push({ service: serviceName, diff --git a/src/commands/down.ts b/src/commands/down.ts index 6eaa767..9cf3e19 100644 --- a/src/commands/down.ts +++ b/src/commands/down.ts @@ -8,6 +8,7 @@ import { runCommand, stopGatewayIfUnused } from "../docker.js"; import { PegasContract } from "../types.js"; import { withRuntimeLock } from "../runtime-lock.js"; import { stripOwnerBlocks, writeRouteFileAtomically } from "../route-merge.js"; +import { createSharedInfraDeps, releaseProjectSharedPools } from "../shared-infra.js"; export interface DownOptions { gatewayDir?: string; @@ -171,6 +172,22 @@ async function handleDownUnlocked(options: DownOptions = {}): Promise { { runtimeId ||= getProjectRuntimeId(projectRoot, projectName); await removeProjectContainers(runtimeId, requestedEnvironment); + // issue #29: освобождаем аренды проекта в разделяемых пулах и + // останавливаем опустевшие инстансы. Данные арендаторов не удаляются: + // том пула сохраняется, из реестра уходит только ссылка на проект. + const sharedRelease = await releaseProjectSharedPools( + createSharedInfraDeps(), + runtimeId, + requestedEnvironment + ); + if (!isJson && sharedRelease.released > 0) { + console.log( + ` ♻️ Shared pools: released ${sharedRelease.released} tenant lease(s)` + + (sharedRelease.stoppedPools.length > 0 + ? `, stopped empty pool(s): ${sharedRelease.stoppedPools.join(", ")}` + : "") + ); + } const gatewayResult = await stopGatewayIfUnused(gatewayDir); if (!gatewayResult.success) { throw new Error(gatewayResult.stderr || "Failed to reconcile shared Gateway lifecycle."); diff --git a/src/commands/status.dashboard.test.ts b/src/commands/status.dashboard.test.ts new file mode 100644 index 0000000..307e3f6 --- /dev/null +++ b/src/commands/status.dashboard.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect } from "bun:test"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import fs from "node:fs/promises"; +import { + matchContainerForRoute, + parseCaddyProjectFile, + inferServiceFromDomain, + inferServiceFromUpstream, + probeTcpPort, + firstHostPort, + shortEnv, + formatUptime, + formatMemUsage, + type DockerContainerInfo, +} from "./status.js"; + +/** + * Issue #9: сопоставление маршрутов с контейнерами по меткам Pegas. + * + * Критерии приёмки issue: + * - тесты покрывают несколько проектов, сценариев и сервисов с одинаковыми именами; + * - статус корректно показывает running/stopped/missing; + * - отсутствующий контейнер не сопоставляется с контейнером другого проекта. + */ + +/** Фабрика контейнера с метками Pegas v3 (те, что ставит генератор манифеста). */ +function labeled( + over: Partial & { id: string; pegasProject: string; pegasProjectName: string; pegasScenario: string; pegasService: string } +): DockerContainerInfo { + return { + name: `pegas-${over.pegasProjectName}-${over.pegasService}-${over.pegasScenario}-${over.pegasProject}`, + image: "img:latest", + state: "running", + status: "Up 2 hours (healthy)", + networks: ["pegas-network"], + ports: "", + composeProject: `pegas-${over.pegasProjectName}-${over.pegasScenario}-${over.pegasProject}`, + composeService: over.pegasService, + ...over, + } as DockerContainerInfo; +} + +describe("issue #9: сопоставление по меткам, несколько проектов и сценариев", () => { + // Три проекта, в каждом сервис с именем "db"; два сценария — одинаковые имена + // сервисов обязаны различаться метками, а не совпадать по подстроке имени. + const containers: DockerContainerInfo[] = [ + labeled({ id: "a-dev", pegasProject: "aaa111", pegasProjectName: "alpha", pegasScenario: "development", pegasService: "db" }), + labeled({ id: "a-prod", pegasProject: "aaa111", pegasProjectName: "alpha", pegasScenario: "production", pegasService: "db" }), + labeled({ id: "b-dev", pegasProject: "bbb222", pegasProjectName: "beta", pegasScenario: "development", pegasService: "db" }), + labeled({ id: "b-web", pegasProject: "bbb222", pegasProjectName: "beta", pegasScenario: "development", pegasService: "web" }), + labeled({ id: "c-dev", pegasProject: "ccc333", pegasProjectName: "gamma", pegasScenario: "development", pegasService: "api" }), + ]; + + it("одноимённые сервисы разных проектов различаются (критерий приёмки #9)", () => { + expect(matchContainerForRoute("aaa111-development-db:5432", "alpha", containers, { service: "db", env: "development" })?.id).toBe("a-dev"); + expect(matchContainerForRoute("bbb222-development-db:5432", "beta", containers, { service: "db", env: "development" })?.id).toBe("b-dev"); + }); + + it("сценарии одного проекта различаются: development ≠ production", () => { + expect(matchContainerForRoute("aaa111-production-db:5432", "alpha", containers, { service: "db", env: "production" })?.id).toBe("a-prod"); + expect(matchContainerForRoute("aaa111-development-db:5432", "alpha", containers, { service: "db", env: "development" })?.id).toBe("a-dev"); + }); + + it("отсутствующий сервис НЕ сопоставляется с контейнером другого проекта", () => { + // 'web' есть только в beta; в alpha его нет — матчинг обязан вернуть undefined + expect(matchContainerForRoute("aaa111-development-web:3000", "alpha", containers, { service: "web", env: "development" })).toBeUndefined(); + // проект, которого вообще нет среди контейнеров + expect(matchContainerForRoute("zzz-development-db:5432", "delta", containers, { service: "db", env: "development" })).toBeUndefined(); + // имя сервиса чужого проекта не должно «перетекать» + expect(matchContainerForRoute("ccc333-development-api:80", "alpha", containers, { service: "api", env: "development" })).toBeUndefined(); + }); + + it("upstream-алиас без метаданных маршрута матчится по тройке меток", () => { + // route-файлы, записанные до появления строки `# pegas:...` + expect(matchContainerForRoute("bbb222-development-web:80", "beta", containers)?.id).toBe("b-web"); + expect(matchContainerForRoute("ccc333-development-api:80", "gamma", containers)?.id).toBe("c-dev"); + }); + + it("сценарий с дефисом в имени не ломает разбор алиаса", () => { + const withDash: DockerContainerInfo[] = [ + labeled({ id: "d1", pegasProject: "ddd444", pegasProjectName: "delta", pegasScenario: "pre-prod", pegasService: "db" }), + ]; + // Имя сервиса и сценарий содержат дефисы: прежнее разбиение по дефису было неоднозначным, + // сверка с тройкой меток целиком — однозначна. + expect(matchContainerForRoute("ddd444-pre-prod-db:5432", "delta", withDash)?.id).toBe("d1"); + // Чужой сценарий того же проекта не матчится + expect(matchContainerForRoute("ddd444-prod-db:5432", "delta", withDash)).toBeUndefined(); + }); + + it("контейнеры compose без меток Pegas матчатся по compose-меткам", () => { + const legacy: DockerContainerInfo[] = [ + { + id: "l1", name: "/proj1_web_1", image: "app:latest", state: "running", status: "Up", + networks: ["pegas-network"], ports: "3000", composeProject: "proj1", composeService: "web", + }, + ]; + expect(matchContainerForRoute("web:3000", "proj1", legacy)?.id).toBe("l1"); + expect(matchContainerForRoute("web:3000", "other", legacy)).toBeUndefined(); + }); + + it("host-маршруты (driver: bun) не имеют контейнера", () => { + for (const up of ["host.docker.internal:3000", "127.0.0.1:3000", "localhost:3000"]) { + expect(matchContainerForRoute(up, "alpha", containers, { service: "db", env: "development" })).toBeUndefined(); + } + }); + + it("регистр имён не влияет на сопоставление", () => { + expect(matchContainerForRoute("x:1", "ALPHA", containers, { service: "DB", env: "DEVELOPMENT" })?.id).toBe("a-dev"); + }); +}); + +describe("issue #30: разбор route-файла с метаданными", () => { + it("строка `# pegas:...` даёт точные сервис, сценарий и проект", () => { + const content = [ + "# Auto-generated by Pegas for project: demo [development]", + "# pegas:project=demo | service=booking-client | env=development | driver=bun | owner=connect | port=3001", + "booking.demo.localhost {", + " reverse_proxy host.docker.internal:3001", + "}", + ].join("\n"); + const routes = parseCaddyProjectFile(content, "demo-development.caddy"); + expect(routes.length).toBe(1); + expect(routes[0].projectName).toBe("demo"); + expect(routes[0].service).toBe("booking-client"); + expect(routes[0].serviceSource).toBe("meta"); + expect(routes[0].env).toBe("development"); + expect(routes[0].meta?.driver).toBe("bun"); + expect(routes[0].meta?.port).toBe(3001); + }); + + it("legacy-файл без метаданных: имя сервиса выводится из домена, сценарий — из имени файла", () => { + const content = [ + "# Auto-generated by Pegas for project: 815records [development]", + "site.815records.localhost {", + " reverse_proxy host.docker.internal:3000", + "}", + ].join("\n"); + const routes = parseCaddyProjectFile(content, "815records-development.caddy"); + expect(routes[0].projectName).toBe("815records"); + expect(routes[0].service).toBe("site"); + expect(routes[0].serviceSource).toBe("inferred"); + expect(routes[0].env).toBe("development"); + }); + + it("файл без шапки Pegas: сценарий отсекается от имени проекта по белому списку", () => { + const content = "app.exogram.localhost {\n reverse_proxy host.docker.internal:3100\n}\n"; + const routes = parseCaddyProjectFile(content, "exogram-development.caddy"); + // Прежнее поведение давало projectName "exogram-development" и service "host.docker.internal" + expect(routes[0].projectName).toBe("exogram"); + expect(routes[0].env).toBe("development"); + expect(routes[0].service).toBe("app"); + expect(routes[0].service).not.toBe("host.docker.internal"); + }); + + it("суффикс вне белого списка не отсекается: имя проекта с дефисом сохраняется", () => { + const content = "web.my-app.caddy.localhost {\n reverse_proxy host.docker.internal:3000\n}\n"; + const routes = parseCaddyProjectFile(content, "my-app-tools.caddy"); + expect(routes[0].projectName).toBe("my-app-tools"); + }); + + it("SPA-маршрут с несколькими reverse_proxy даёт одну строку, а не дубли", () => { + const content = [ + "# pegas:project=demo | service=web | env=development | driver=docker | owner=deploy", + "app.demo.localhost {", + " handle /api/* {", + " reverse_proxy abc123-development-api:8000", + " }", + " handle {", + " reverse_proxy abc123-development-web:3000", + " }", + "}", + ].join("\n"); + const routes = parseCaddyProjectFile(content, "demo-development.caddy"); + expect(routes.length).toBe(1); + expect(routes[0].upstream).toBe("abc123-development-api:8000"); + }); + + it("www-редирект добавляется к доменам, но не порождает отдельный маршрут", () => { + const content = [ + "www.example.com {", + " redir https://example.com{uri} permanent", + "}", + "", + "# pegas:project=example | service=web | env=production | driver=docker | owner=deploy", + "example.com {", + " reverse_proxy abc-production-web:80", + "}", + ].join("\n"); + const routes = parseCaddyProjectFile(content, "example.caddy"); + expect(routes.length).toBe(1); + expect(routes[0].domains).toEqual(["example.com", "www.example.com"]); + }); + + it("несколько блоков в одном файле: каждый получает свои метаданные", () => { + const content = [ + "# pegas:project=demo | service=site | env=development | driver=bun | owner=connect | port=3000", + "site.demo.localhost {", + " reverse_proxy host.docker.internal:3000", + "}", + "", + "# pegas:project=demo | service=worker | env=development | driver=docker | owner=deploy | port=3200", + "worker.demo.localhost {", + " reverse_proxy abc123-development-worker:3200", + "}", + ].join("\n"); + const routes = parseCaddyProjectFile(content, "demo-development.caddy"); + expect(routes.length).toBe(2); + const byService = Object.fromEntries(routes.map((r) => [r.service, r])); + expect(byService.site.meta?.driver).toBe("bun"); + expect(byService.worker.meta?.driver).toBe("docker"); + expect(byService.worker.meta?.port).toBe(3200); + }); +}); + +describe("issue #30: вывод имени сервиса", () => { + it("dev-домен Pegas `..localhost` разбирается точно", () => { + expect(inferServiceFromDomain("booking.demo-app.localhost", "demo-app")).toBe("booking"); + expect(inferServiceFromDomain("site.815records.localhost", "815records")).toBe("site"); + }); + + it("произвольный домен: первая метка как приближение, www отбрасывается", () => { + expect(inferServiceFromDomain("api.example.com", "example")).toBe("api"); + expect(inferServiceFromDomain("www.example.com", "example")).toBe("example"); + }); + + it("пустой домен не даёт имени", () => { + expect(inferServiceFromDomain("", "p")).toBe(""); + }); + + it("upstream-вывод: имя сервиса — последняя часть алиаса", () => { + expect(inferServiceFromUpstream("abc123-development-worker:3200")).toBe("worker"); + expect(inferServiceFromUpstream("host.docker.internal:3000")).toBe(""); + }); +}); + +describe("issue #30: реальный TCP-пробник host-процесса", () => { + it("порт со слушающим сервером определяется как живой", async () => { + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + try { + await expect(probeTcpPort(port, "127.0.0.1", 500)).resolves.toBe(true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("закрытый порт определяется как offline (ECONNREFUSED)", async () => { + // Занимаем порт, закрываем — гарантированно свободный адрес для отказа. + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + await expect(probeTcpPort(port, "127.0.0.1", 500)).resolves.toBe(false); + }); + + it("некорректный порт не роняет status и не подключается", async () => { + await expect(probeTcpPort(0)).resolves.toBe(false); + await expect(probeTcpPort(-1)).resolves.toBe(false); + await expect(probeTcpPort(99999)).resolves.toBe(false); + await expect(probeTcpPort(Number.NaN)).resolves.toBe(false); + }); + + it("таймаут не даёт зависнуть: нерезолвящийся адрес завершается быстро", async () => { + const started = Date.now(); + // 10.255.255.1 обычно маршрутизируется в никуда — проверка именно таймаута. + const result = await probeTcpPort(9, "10.255.255.1", 120); + expect(result).toBe(false); + expect(Date.now() - started).toBeLessThan(2000); + }); +}); + +describe("issue #30: форматирование колонок дашборда", () => { + it("firstHostPort вытаскивает хостовый порт из строки ports", () => { + // Реальные форматы `docker ps --format '{{.Ports}}'` + expect(firstHostPort("0.0.0.0:5432->5432/tcp")).toBe("5432"); + expect(firstHostPort("[::]:5432->5432/tcp")).toBe("5432"); + expect(firstHostPort("0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp")).toBe("5432"); + expect(firstHostPort("0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/udp, 2019/tcp")).toBe("80"); + expect(firstHostPort("-")).toBe(""); + expect(firstHostPort(undefined)).toBe(""); + }); + + it("expose без публикации не даёт хост-порта: эндпоинт остаётся внутренним", () => { + // `5432/tcp` — порт доступен только внутри сети pegas-network; прежняя + // реализация вернула бы "5432" и дашборд пообещал бы localhost:5432 + expect(firstHostPort("5432/tcp")).toBe(""); + expect(firstHostPort("2019/tcp")).toBe(""); + }); + + it("shortEnv сокращает имена сценариев для узкой колонки", () => { + expect(shortEnv("development")).toBe("dev"); + expect(shortEnv("production")).toBe("prod"); + expect(shortEnv("staging")).toBe("stage"); + expect(shortEnv("pre-prod")).toBe("pre-prod"); + }); + + it("formatUptime сжимает RunningFor до компактной формы", () => { + expect(formatUptime("2 hours ago")).toBe("2h"); + expect(formatUptime("3 days ago")).toBe("3d"); + expect(formatUptime("45 minutes ago")).toBe("45m"); + expect(formatUptime(undefined)).toBe("-"); + expect(formatUptime("неизвестно")).toBe("-"); + }); + + it("formatMemUsage оставляет использованную память без лимита хоста", () => { + expect(formatMemUsage("31.16MiB / 7.734GiB")).toBe("31.16MiB"); + expect(formatMemUsage(undefined)).toBe("-"); + }); +}); + +describe("issue #30: JSON-дашборд", () => { + it("handleStatus выдаёт services, projects (совместимость) и issues", async () => { + const gw = await fs.mkdtemp(path.join(os.tmpdir(), "pegas-status-dash-")); + const confDir = path.join(gw, "conf.d"); + await fs.mkdir(confDir, { recursive: true }); + await fs.writeFile( + path.join(confDir, "demo-development.caddy"), + [ + "# Auto-generated by Pegas for project: demo [development]", + "# pegas:project=demo | service=site | env=development | driver=bun | owner=connect | port=3000", + "site.demo.localhost {", + " reverse_proxy host.docker.internal:3000", + "}", + ].join("\n") + "\n" + ); + + const logs: string[] = []; + const origLog = console.log; + console.log = (...args: unknown[]) => logs.push(args.join(" ")); + try { + await (await import("./status.js")).handleStatus({ gatewayDir: gw, json: true, skipProbes: true }); + } finally { + console.log = origLog; + await fs.rm(gw, { recursive: true, force: true }); + } + + const payload = JSON.parse(logs[0]); + // Единый дашборд: сервис — единица вывода + expect(Array.isArray(payload.services)).toBe(true); + const site = payload.services.find((s: any) => s.project === "demo" && s.service === "site"); + expect(site).toBeDefined(); + expect(site.driver).toBe("bun"); + expect(site.env).toBe("development"); + expect(site.gateway).toBe("linked"); + expect(site.domains).toContain("site.demo.localhost"); + // Обратная совместимость: прежняя форма списка маршрутов сохранена (e2e читает projects) + expect(Array.isArray(payload.projects)).toBe(true); + expect(payload.activeRoutesCount).toBe(1); + expect(payload.network.name).toBe("pegas-network"); + }); +}); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 47f45db..cac88e3 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -93,7 +93,7 @@ site.example.com { expect(output).toContain("Gateway Location"); }); - it("matchContainerForRoute correctly matches containers by compose, exact name, or prefix", () => { + it("matchContainerForRoute сопоставляет по меткам, а не по эвристике имён (issue #9)", () => { const containers = [ { id: "1", @@ -108,40 +108,51 @@ site.example.com { }, { id: "2", - name: "/exact-service", - image: "exact:latest", + name: "/pegas-myapp-api-production-abc123", + image: "api:latest", state: "running", status: "Up", - networks: [], + networks: ["pegas-network"], ports: "", + pegasProject: "abc123", + pegasProjectName: "myapp", + pegasScenario: "production", + pegasService: "api", }, { id: "3", - name: "/proj2-worker", - image: "worker:latest", + name: "/pegas-myapp-db-development-abc123", + image: "postgres:18", state: "running", status: "Up", - networks: [], - ports: "", + networks: ["pegas-network"], + ports: "5432", + pegasProject: "abc123", + pegasProjectName: "myapp", + pegasScenario: "development", + pegasService: "db", }, ]; - // Host dev routes return undefined + // Host-маршруты (driver: bun) не имеют контейнера expect(matchContainerForRoute("host.docker.internal:3000", "proj1", containers)).toBeUndefined(); expect(matchContainerForRoute("127.0.0.1:3000", "proj1", containers)).toBeUndefined(); expect(matchContainerForRoute("localhost:3000", "proj1", containers)).toBeUndefined(); - // Compose match - expect(matchContainerForRoute("web:3000", "proj1", containers)?.id).toBe("1"); + // 1. Метки Pegas + метаданные маршрута (точный путь) + expect(matchContainerForRoute("x:1", "myapp", containers, { service: "api", env: "production" })?.id).toBe("2"); - // Exact name match - expect(matchContainerForRoute("exact-service:8080", "other", containers)?.id).toBe("2"); + // 2. Сетевой алиас -- сверяется с тройкой меток + expect(matchContainerForRoute("abc123-development-db:5432", "myapp", containers)?.id).toBe("3"); - // Prefix match - expect(matchContainerForRoute("worker:5000", "proj2", containers)?.id).toBe("3"); + // 3. Метки compose (контейнеры не из манифеста v3) + expect(matchContainerForRoute("web:3000", "proj1", containers)?.id).toBe("1"); - // No match - expect(matchContainerForRoute("unknown:9999", "other", containers)).toBeUndefined(); + // Критерий приёмки #9: отсутствующий сервис НЕ сопоставляется с контейнером другого проекта + expect(matchContainerForRoute("web:3000", "other", containers)).toBeUndefined(); + expect(matchContainerForRoute("api:8000", "myapp", containers, { service: "api", env: "development" })).toBeUndefined(); + // Имя сервиса из другого проекта не матчится: сервис 'db' есть только в myapp/development + expect(matchContainerForRoute("db:5432", "other", containers, { service: "db", env: "development" })).toBeUndefined(); }); it("formatDockerStatus formats various container states and host dev properly", () => { @@ -216,7 +227,8 @@ site.example.com { ) ).toBe("pg.example.com"); - // 4. Default hostPort fallback + // 4. Порт проброшен на хост, меток нет: фактический адрес, а не выдуманный + // домен (issue #30: `..localhost` не существует ни в DNS, ни в Caddy) expect( resolveInfraEndpoint( {} as any, @@ -224,9 +236,9 @@ site.example.com { "redis", "6379->6379" ) - ).toBe("redis.myproj.localhost:6379"); + ).toBe("localhost:6379"); - // 5. No port fallback + // 5. Порт не проброшен: сервис доступен только внутри сети pegas-network expect( resolveInfraEndpoint( {} as any, @@ -234,6 +246,6 @@ site.example.com { "redis", "-" ) - ).toBe("-"); + ).toBe("(internal)"); }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index b0545c2..4b6b952 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; -import { findGatewayDir, listRoutes } from "../gateway.js"; +import net from "node:net"; +import { findGatewayDir, findProjectRoot, getConfDir } from "../gateway.js"; +import { CONTRACT_FILENAME } from "../contract.js"; import { checkDocker, checkDockerCompose, @@ -9,6 +11,7 @@ import { runCommand, } from "../docker.js"; import { renderTable } from "../table.js"; +import { parseRouteMeta, type RouteMeta } from "../route-meta.js"; export interface DockerContainerInfo { id: string; @@ -21,19 +24,49 @@ export interface DockerContainerInfo { composeProject?: string; composeService?: string; pegasProject?: string; + pegasProjectName?: string; + pegasScenario?: string; pegasService?: string; pegasRole?: string; pegasDomains?: string; pegasEndpoint?: string; + /** Память контейнера (`docker stats`, формат "12.3MiB / 7.7GiB"). */ + memUsage?: string; + /** Время с запуска (`docker ps` RunningFor), например "2 hours ago". */ + runningFor?: string; } -export interface ParsedProjectRoute { - projectName: string; +/** Строка единого дашборда (issue #30): один сервис — одна строка. */ +export interface ServiceRow { + project: string; + service: string; + env: string; + driver: "docker" | "bun" | "unknown"; + /** Как сервис доступен снаружи: домен, localhost:, publicHost или (internal). */ + endpoint: string; + /** Куда шлюз проксирует: container-alias:port или 127.0.0.1:port. */ + target: string; + /** Состояние маршрута в шлюзе. */ + gateway: "linked" | "routed" | "direct" | "none"; + /** Реальное состояние процесса/контейнера. */ + health: string; + state: string; + /** Ресурсы и аптайм: "48 MB (2h)" для контейнера, "host" для host-процесса. */ + resources: string; + /** Домены маршрута (для JSON-потребителей). */ domains: string[]; - upstream: string; - filePath: string; + container?: string; + containerId?: string; + image?: string; + /** Логический порт сервиса (из метаданных маршрута или метки контейнера). */ + port?: number; + /** Строка принадлежит проекту, из каталога которого запущен status. */ + current: boolean; + /** Проблема, из-за которой сервис не отвечает (для блока подсказок). */ + issue?: string; } +/** Совместимость: строка прежнего дашборда маршрутов (поле `projects` в JSON). */ export interface ProjectDashboardItem { project: string; domains: string[]; @@ -45,8 +78,19 @@ export interface ProjectDashboardItem { containerId?: string; } -export function parseCaddyTopLevelBlocks(content: string): Array<{ header: string; body: string }> { - const blocks: Array<{ header: string; body: string }> = []; +export interface CaddyTopLevelBlock { + header: string; + body: string; + /** + * Комментарии, идущие подряд непосредственно перед заголовком блока. + * Pegas записывает туда метаданные маршрута (`# pegas:service=... | env=... | driver=...`), + * поэтому они обязаны переживать merge/strip (issue #30). + */ + leadingComments: string[]; +} + +export function parseCaddyTopLevelBlocks(content: string): CaddyTopLevelBlock[] { + const blocks: CaddyTopLevelBlock[] = []; let i = 0; const n = content.length; @@ -55,8 +99,19 @@ export function parseCaddyTopLevelBlocks(content: string): Array<{ header: strin if (openBrace === -1) break; const beforeText = content.slice(i, openBrace).trim(); - const lines = beforeText.split("\n").map((l) => l.trim()).filter((l) => Boolean(l) && !l.startsWith("#")); - const header = lines[lines.length - 1] || ""; + const lines = beforeText.split("\n").map((l) => l.trim()).filter((l) => Boolean(l)); + const nonComment = lines.filter((l) => !l.startsWith("#")); + const header = nonComment[nonComment.length - 1] || ""; + + // Комментарии, стоящие подряд прямо перед заголовком блока, — его метаданные. + // Всё, что выше (шапка файла, комментарии предыдущих блоков), к блоку не относится. + const headerIdx = header ? lines.lastIndexOf(header) : -1; + const leadingComments: string[] = []; + if (headerIdx > 0) { + for (let k = headerIdx - 1; k >= 0 && lines[k].startsWith("#"); k--) { + leadingComments.unshift(lines[k]); + } + } let depth = 1; let j = openBrace + 1; @@ -83,7 +138,7 @@ export function parseCaddyTopLevelBlocks(content: string): Array<{ header: strin if (depth === 0) { const body = content.slice(openBrace + 1, j - 1); if (header && !header.startsWith("#")) { - blocks.push({ header, body }); + blocks.push({ header, body, leadingComments }); } i = j; } else { @@ -94,15 +149,86 @@ export function parseCaddyTopLevelBlocks(content: string): Array<{ header: strin return blocks; } +/** + * Имена сценариев, которыми Pegas суффиксирует route-файл + * (`getProjectRouteFilePath`: `-.caddy`). Белый список нужен, + * чтобы не отрезать часть настоящего имени проекта (`my-app-tools`). + */ +const FILE_ENV_SUFFIXES = new Set(["development", "dev", "production", "prod", "stage", "staging"]); + +export interface ParsedProjectRoute { + projectName: string; + domains: string[]; + upstream: string; + filePath: string; + /** Метаданные маршрута из комментария `# pegas:...` (null — файл записан до 3.3.0). */ + meta: RouteMeta | null; + /** Источник имени сервиса: метаданные точны, вывод из домена — приближение. */ + serviceSource: "meta" | "inferred"; + /** Имя сервиса: из метаданных или выведено из домена маршрута. */ + service: string; + /** Сценарий маршрута. */ + env: string; +} + +/** + * Вывод имени сервиса из домена маршрута (issue #30). + * + * Dev-домены Pegas формируются как `..localhost`, поэтому для + * них имя сервиса извлекается точно. Для произвольного домена берётся первая + * метка — это приближение, и вызывающий код помечает источник как `inferred`. + * + * Используется только для route-файлов, записанных до появления строки + * метаданных `# pegas:...`: при её наличии имя сервиса берётся из метаданных. + */ +export function inferServiceFromDomain(domain: string, projectName: string): string { + if (!domain) return ""; + const labels = domain.toLowerCase().split("."); + // Точный случай: ..localhost + const localIdx = labels.lastIndexOf("localhost"); + if (localIdx >= 2 && labels.slice(1, localIdx).join(".") === projectName.toLowerCase()) { + return labels[0]; + } + // Приближение: первая метка домена (www отбрасывается — это алиас, не сервис) + const first = labels[0] === "www" ? labels[1] ?? "" : labels[0]; + return first ?? ""; +} + +/** + * Вывод имени сервиса из scoped-upstream `--`. + * Имя сервиса — последняя часть после дефиса; разбор неоднозначен, поэтому + * применяется только когда домен имени не дал (например, publicHost TCP-маршрут). + */ +export function inferServiceFromUpstream(upstream: string): string { + const host = upstream.split(":")[0]; + if (!host || host.includes(".")) return ""; + const lastDash = host.lastIndexOf("-"); + if (lastDash <= 0 || lastDash === host.length - 1) return ""; + return host.slice(lastDash + 1); +} + export function parseCaddyProjectFile(content: string, filename: string): ParsedProjectRoute[] { - let projectName = path.basename(filename, ".caddy"); - const projectCommentMatch = content.match(/#\s*Auto-generated by Pegas for project:\s*([^\n\r\[]+)/i); + const base = path.basename(filename, ".caddy"); + let projectName = base; + let fileEnv: string | undefined; + + // Шапка файла — точный источник: пишется connect/deploy вместе с маршрутами. + const projectCommentMatch = content.match(/#\s*Auto-generated by Pegas for project:\s*([^\n\r\[]+)(?:\[([^\]]+)\])?/i); if (projectCommentMatch && projectCommentMatch[1]) { projectName = projectCommentMatch[1].trim(); - } else { - const envSuffixMatch = projectName.match(/^(.+?)-(stage|prod|production|dev|development)$/i); - if (envSuffixMatch) { - projectName = envSuffixMatch[1]; + fileEnv = projectCommentMatch[2]?.trim() || fileEnv; + } + + // Файлы, записанные до появления шапки (или рукописные): имя и сценарий берутся + // из имени файла. Суффикс сценария отсекается только по белому списку. + if (!fileEnv) { + const lastDash = base.lastIndexOf("-"); + if (lastDash > 0) { + const suffix = base.slice(lastDash + 1).toLowerCase(); + if (FILE_ENV_SUFFIXES.has(suffix)) { + fileEnv = suffix; + if (!projectCommentMatch) projectName = base.slice(0, lastDash); + } } } @@ -129,6 +255,8 @@ export function parseCaddyProjectFile(content: string, filename: string): Parsed .map((d) => d.trim()) .filter((d) => Boolean(d) && !d.startsWith("#")); + const meta = parseRouteMeta(block.leadingComments); + const proxyMatches = body.matchAll(/reverse_proxy\s+([^\s\n{}]+)/g); for (const pm of proxyMatches) { const upstream = pm[1].trim(); @@ -140,22 +268,59 @@ export function parseCaddyProjectFile(content: string, filename: string): Parsed } } + // Метаданные маршрута приоритетны: имя проекта из них точнее, чем вывод + // из имени файла (файл может содержать блоки нескольких владельцев). + const project = meta?.project ?? projectName; + // Имя сервиса: метаданные → домен → upstream. Первые два источника точны, + // upstream-вывод неоднозначен, поэтому идёт последним. + const service = + meta?.service || + inferServiceFromDomain(allDomains[0] ?? "", project) || + inferServiceFromUpstream(upstream); + const env = meta?.env || fileEnv || ""; + routes.push({ - projectName, + projectName: project, domains: allDomains, upstream, filePath: filename, + meta: meta ?? null, + service, + serviceSource: meta?.service ? "meta" : "inferred", + env, }); + // Один блок — один маршрут: дальнейшие reverse_proxy (например, SPA api-prefix) + // не должны порождать дубли строк дашборда. + break; } } return routes; } +/** + * Сопоставление маршрута с контейнером по стабильным меткам Pegas (issue #9). + * + * Прежняя эвристика сравнивала `com.docker.compose.project` с именем проекта из + * контракта и сопоставляла контейнер по подстроке имени. В v3 compose-проект + * содержит runtimeId, а не projectName, поэтому статус всегда показывал + * «Not started»; сопоставление по подстроке имени давало и обратную ошибку — + * контейнер чужого проекта принимался за свой (критерий приёмки issue #9: + * отсутствующий контейнер не должен сопоставляться с контейнером другого проекта). + * + * Порядок источников, все они основаны на метках, а не на схеме имён: + * 1. метки Pegas + метаданные маршрута (`projectName` + `service` + `env`); + * 2. сетевой алиас `--` — сверяется с тройкой + * меток контейнера посимвольно, поэтому разбор имени не неоднозначен + * (работает и для route-файлов, записанных до появления метаданных); + * 3. метки compose (`com.docker.compose.project` + `...service`) — для + * контейнеров, поднятых не манифестом Pegas v3. + */ export function matchContainerForRoute( upstream: string, projectName: string, - containers: DockerContainerInfo[] + containers: DockerContainerInfo[], + meta?: { service?: string; env?: string } | null, ): DockerContainerInfo | undefined { const hostPart = upstream.split(":")[0]; @@ -163,24 +328,40 @@ export function matchContainerForRoute( return undefined; } - const composeMatch = containers.find( - (c) => - c.composeProject?.toLowerCase() === projectName.toLowerCase() && - c.composeService?.toLowerCase() === hostPart.toLowerCase() - ); - if (composeMatch) return composeMatch; + const sameName = (a: string | undefined, b: string | undefined): boolean => + Boolean(a) && Boolean(b) && a!.toLowerCase() === b!.toLowerCase(); - const exactNameMatch = containers.find((c) => { - const cleanName = c.name.replace(/^\//, ""); - return cleanName === hostPart; - }); - if (exactNameMatch) return exactNameMatch; + // 1. Метки Pegas + метаданные маршрута — точное совпадение проекта, сервиса и сценария. + if (meta?.service) { + const labeled = containers.find((c) => { + if (!sameName(c.pegasProjectName, projectName)) return false; + if (!sameName(c.pegasService, meta.service)) return false; + if (meta.env && !sameName(c.pegasScenario, meta.env)) return false; + return true; + }); + if (labeled) return labeled; + } + + // 2. Сетевой алиас: сверяем upstream с тройкой меток, а не разбираем имя. + // Точное совпадение всей строки устраняет неоднозначность разделения + // «runtimeId / сценарий / сервис», когда сценарий или сервис содержат дефисы. + // Принадлежность к проекту подтверждается отдельно: runtimeId в алиасе — + // не projectName, поэтому без этой проверки контейнер чужого проекта + // с тем же именем сервиса матчился бы (критерий приёмки issue #9). + const byAlias = containers.find( + (c) => c.pegasProject && c.pegasScenario && c.pegasService && + `${c.pegasProject}-${c.pegasScenario}-${c.pegasService}` === hostPart && + (!projectName || sameName(c.pegasProjectName, projectName)) + ); + if (byAlias) return byAlias; - const prefixMatch = containers.find((c) => { - const cleanName = c.name.replace(/^\//, ""); - return cleanName.startsWith(`${projectName}_`) || cleanName.startsWith(`${projectName}-`); + // 3. Метки compose: имя сервиса внутри compose-проекта, совпадающего с проектом. + const byCompose = containers.find((c) => { + if (!c.composeService || !c.composeProject) return false; + if (c.composeService.toLowerCase() !== hostPart.toLowerCase()) return false; + return c.composeProject.toLowerCase() === projectName.toLowerCase(); }); - if (prefixMatch) return prefixMatch; + if (byCompose) return byCompose; return undefined; } @@ -262,58 +443,125 @@ export function formatDockerStatus(container?: DockerContainerInfo, upstream?: s }; } +/** Разделитель полей в `--format`; не встречается в именах и метках Pegas. */ +const PS_DELIMITER = "\x1f"; + +/** + * Список контейнеров с метками Pegas. + * + * Метки читаются через `{{.Label "..."}}`, а не парсингом строки `{{.Labels}}`: + * значение метки `pegas.domains` само содержит запятые, поэтому прежний regex + * `([^,]+)` обрезал список доменов на первом же элементе. + */ export async function getDockerContainers(): Promise { + const format = [ + "{{.ID}}", + "{{.Names}}", + "{{.Image}}", + "{{.State}}", + "{{.Status}}", + "{{.Networks}}", + "{{.Ports}}", + "{{.RunningFor}}", + '{{.Label "com.docker.compose.project"}}', + '{{.Label "com.docker.compose.service"}}', + '{{.Label "pegas.project"}}', + '{{.Label "pegas.project-name"}}', + '{{.Label "pegas.scenario"}}', + '{{.Label "pegas.service"}}', + '{{.Label "pegas.role"}}', + '{{.Label "pegas.domains"}}', + '{{.Label "pegas.endpoint"}}', + '{{.Label "pegas.gateway"}}', + ].join(PS_DELIMITER); + try { - const res = await runCommand("docker", ["ps", "-a", "--format", "{{json .}}"]); + const res = await runCommand("docker", ["ps", "-a", "--format", format]); if (!res.success || !res.stdout.trim()) { return []; } - const lines = res.stdout.trim().split("\n"); const containers: DockerContainerInfo[] = []; - - for (const line of lines) { - try { - const item = JSON.parse(line.trim()); - const labels = item.Labels || ""; - const projectMatch = labels.match(/com\.docker\.compose\.project=([^,]+)/); - const serviceMatch = labels.match(/com\.docker\.compose\.service=([^,]+)/); - const pegasProjectMatch = labels.match(/pegas\.project-name=([^,]+)/); - const pegasServiceMatch = labels.match(/pegas\.service=([^,]+)/); - const pegasRoleMatch = labels.match(/pegas\.role=([^,]+)/); - const pegasDomainsMatch = labels.match(/pegas\.domains=([^,]+)/); - const pegasEndpointMatch = labels.match(/pegas\.endpoint=([^,]+)/); - - containers.push({ - id: item.ID || "", - name: item.Names || "", - image: item.Image || "", - state: (item.State || "").toLowerCase(), - status: item.Status || "", - networks: (item.Networks || "").split(",").map((n: string) => n.trim()), - ports: item.Ports || "", - composeProject: projectMatch ? projectMatch[1] : undefined, - composeService: serviceMatch ? serviceMatch[1] : undefined, - pegasProject: pegasProjectMatch ? pegasProjectMatch[1] : undefined, - pegasService: pegasServiceMatch ? pegasServiceMatch[1] : undefined, - pegasRole: pegasRoleMatch ? pegasRoleMatch[1] : undefined, - pegasDomains: pegasDomainsMatch ? pegasDomainsMatch[1] : undefined, - pegasEndpoint: pegasEndpointMatch ? pegasEndpointMatch[1] : undefined, - }); - } catch {} + for (const line of res.stdout.trim().split("\n")) { + const f = line.split(PS_DELIMITER); + if (f.length < 17) continue; + const pick = (v: string | undefined): string | undefined => { + const t = (v ?? "").trim(); + return t ? t : undefined; + }; + containers.push({ + id: (f[0] ?? "").trim(), + name: (f[1] ?? "").trim(), + image: (f[2] ?? "").trim(), + state: (f[3] ?? "").trim().toLowerCase(), + status: (f[4] ?? "").trim(), + networks: (f[5] ?? "").split(",").map((n: string) => n.trim()).filter(Boolean), + ports: (f[6] ?? "").trim(), + runningFor: pick(f[7]), + composeProject: pick(f[8]), + composeService: pick(f[9]), + pegasProject: pick(f[10]), + pegasProjectName: pick(f[11]), + pegasScenario: pick(f[12]), + pegasService: pick(f[13]), + pegasRole: pick(f[14]), + pegasDomains: pick(f[15]), + pegasEndpoint: pick(f[16]), + }); } - return containers; } catch { return []; } } +/** Память контейнеров одним вызовом: id → "12.3MiB / 7.7GiB". */ +export async function getContainerMemory(ids: string[]): Promise> { + if (ids.length === 0) return {}; + const res = await runCommand("docker", [ + "stats", "--no-stream", "--format", "{{.ID}}|{{.MemUsage}}", ...ids, + ]); + if (!res.success || !res.stdout.trim()) return {}; + const out: Record = {}; + for (const line of res.stdout.trim().split("\n")) { + const [id, mem] = line.split("|"); + if (id && mem) out[id.trim()] = mem.trim(); + } + return out; +} + +/** + * Реальная проверка host-процесса (issue #30). + * + * Прежний статус `Host (dev)` печатался по факту записи маршрута в Caddyfile, + * поэтому сервис выглядел работающим, даже если `bun run dev` не запущен и + * шлюз отвечает 502. TCP-подключение к порту отражает действительность. + * Таймаут короткий и проверки идут параллельно — status остаётся быстрым. + */ +export function probeTcpPort(port: number, host = "127.0.0.1", timeoutMs = 150): Promise { + if (!Number.isFinite(port) || port <= 0 || port > 65535) return Promise.resolve(false); + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + let settled = false; + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(ok); + }; + socket.setTimeout(timeoutMs); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + export function resolveInfraEndpoint( c: DockerContainerInfo, proj: string, svc: string, - cleanPorts: string + cleanPorts: string, + startDir: string = process.cwd() ): string { if (c.pegasEndpoint) return c.pegasEndpoint; const hostPort = cleanPorts && cleanPorts !== "-" ? cleanPorts.split("->")[0].trim() : ""; @@ -322,10 +570,9 @@ export function resolveInfraEndpoint( return hostPort ? `${firstDomain}:${hostPort}` : firstDomain; } const candidates = [ - path.join(process.cwd(), "pegas.json"), - path.join(process.cwd(), proj, "pegas.json"), - path.join(path.dirname(process.cwd()), proj, "pegas.json"), - path.join("/home/vibe", proj, "pegas.json"), + path.join(startDir, "pegas.json"), + path.join(startDir, proj, "pegas.json"), + path.join(path.dirname(startDir), proj, "pegas.json"), ]; for (const cPath of candidates) { try { @@ -346,13 +593,73 @@ export function resolveInfraEndpoint( } } catch {} } + // issue #30: синтетический домен больше не выдумывается. Порт проброшен на хост — + // показываем фактический адрес; не проброшен — сервис доступен только внутри сети. if (hostPort) { - return `${svc}.${proj}.localhost:${hostPort}`; + return `localhost:${hostPort}`; + } + return "(internal)"; +} + +/** "12.3MiB / 7.7GiB" → "12.3 MB". */ +export function formatMemUsage(memUsage?: string): string { + if (!memUsage) return "-"; + const used = memUsage.split("/")[0]?.trim(); + if (!used) return "-"; + return used.replace(/\s+/g, " "); +} + +/** "2 hours ago" → "2h"; "3 days ago" → "3d". */ +export function formatUptime(runningFor?: string): string { + if (!runningFor) return "-"; + const m = runningFor.match(/(\d+)\s*(second|minute|hour|day|week|month|year)s?/i); + if (!m) return "-"; + const unit = m[2].toLowerCase()[0]; + return `${m[1]}${unit}`; +} + +/** Сокращённое имя сценария для узкой колонки ENV. */ +export function shortEnv(env: string): string { + const lower = env.toLowerCase(); + if (lower === "development") return "dev"; + if (lower === "production") return "prod"; + if (lower === "staging") return "stage"; + return lower; +} + +/** + * Хост-порт из строки `{{.Ports}}` контейнера. + * + * Docker различает публикацию и expose: + * - `0.0.0.0:5432->5432/tcp`, `[::]:5432->5432/tcp` — порт опубликован на хосте; + * - `5432/tcp` — порт доступен только внутри сети `pegas-network` (expose). + * Возвращается только опубликованный порт: иначе эндпоинт `localhost:` + * обещал бы доступ, которого на хосте нет (issue #30 — честные эндпоинты). + */ +export function firstHostPort(ports?: string): string { + if (!ports || ports === "-") return ""; + for (const entry of ports.split(",")) { + const part = entry.trim(); + // Признак публикации — стрелка маппинга «хост -> контейнер». + if (!part.includes("->")) continue; + const hostSide = part.split("->")[0].trim(); + const colon = hostSide.lastIndexOf(":"); + const port = colon >= 0 ? hostSide.slice(colon + 1) : hostSide; + if (/^\d+$/.test(port)) return port; } - return "-"; + return ""; } -export async function handleStatus(options: { gatewayDir?: string; json?: boolean }): Promise { +export interface StatusOptions { + gatewayDir?: string; + json?: boolean; + /** Каталог запуска: его проект помечается ★ в дашборде. */ + cwd?: string; + /** Не опрашивать host-порты и docker stats (быстрый вывод, например в тестах). */ + skipProbes?: boolean; +} + +export async function handleStatus(options: StatusOptions = {}): Promise { const isJson = options.json ?? false; const dockerOk = await checkDocker(); @@ -365,7 +672,7 @@ export async function handleStatus(options: { gatewayDir?: string; json?: boolea const containerStatus = await getGatewayContainerStatus(gatewayDir); const isRunning = containerStatus.success && containerStatus.stdout.includes("caddy"); - const confDir = path.join(gatewayDir, "conf.d"); + const confDir = getConfDir(gatewayDir); const routes: ParsedProjectRoute[] = []; try { const files = await fs.readdir(confDir); @@ -384,62 +691,210 @@ export async function handleStatus(options: { gatewayDir?: string; json?: boolea } } + // Текущий проект: status, запущенный из каталога проекта, помечает его строки ★. + // pegas.json ищется сначала в каталоге запуска: findProjectRoot опирается на + // .git/package.json, которых в минимальном каталоге проекта может не быть. + let currentProject: string | undefined; + const startDir = options.cwd ?? process.cwd(); + try { + const direct = await fs.readFile(path.join(startDir, CONTRACT_FILENAME), "utf-8") + .then((raw) => JSON.parse(raw)?.projectName as string | undefined) + .catch(() => undefined); + if (direct) { + currentProject = direct; + } else { + const projectRoot = await findProjectRoot(startDir); + if (projectRoot) { + const raw = await fs.readFile(path.join(projectRoot, CONTRACT_FILENAME), "utf-8"); + currentProject = JSON.parse(raw)?.projectName; + } + } + } catch {} + const containers = await getDockerContainers(); - const items: ProjectDashboardItem[] = routes.map((route) => { - const matchedContainer = matchContainerForRoute(route.upstream, route.projectName, containers); - const { displayStatus, state, statusText } = formatDockerStatus(matchedContainer, route.upstream); + const appContainers = containers.filter((c) => c.name !== "pegas-gateway" && c.name !== "/pegas-gateway"); + + // Один сервис (project + service + env) — одна строка: домены объединяются, + // несколько route-файлов одного сервиса не дублируют строку. + const rowsByKey = new Map(); + const usedContainerIds = new Set(); + + for (const route of routes) { + const meta = route.meta; + // service/env уже вычислены в parseCaddyProjectFile: метаданные маршрута, + // затем вывод из домена, затем из upstream. Upstream-хост как имя сервиса + // больше не показывается (issue #30: host.docker.internal не имя сервиса). + const serviceName = route.service || route.upstream.split(":")[0]; + const env = route.env; + const driver: ServiceRow["driver"] = meta?.driver ?? "unknown"; + const isHostRoute = + driver === "bun" || + route.upstream.startsWith("host.docker.internal") || + route.upstream.startsWith("127.0.0.1") || + route.upstream.startsWith("localhost"); + + const key = `${route.projectName}::${serviceName}::${env}`; + let row = rowsByKey.get(key); + if (!row) { + row = { + project: route.projectName, + service: serviceName, + env, + driver: isHostRoute ? "bun" : driver === "unknown" ? "unknown" : "docker", + endpoint: "", + target: route.upstream, + gateway: isHostRoute ? "linked" : "routed", + health: "", + state: "unknown", + resources: isHostRoute ? "host" : "-", + domains: [], + port: meta?.port, + current: currentProject === route.projectName, + }; + rowsByKey.set(key, row); + } + for (const d of route.domains) { + if (!row.domains.includes(d)) row.domains.push(d); + } + if (row.port === undefined && meta?.port) row.port = meta.port; + + const matched = isHostRoute + ? undefined + : matchContainerForRoute(route.upstream, route.projectName, appContainers, { + // Выведенное имя сервиса безопасно передавать в сопоставление: при + // ошибке вывода метки просто не совпадут, и ложного сопоставления + // с контейнером другого проекта не возникнет (критерий приёмки #9). + service: route.service || meta?.service, + env, + }); + if (matched) { + row.container = matched.name.replace(/^\//, ""); + row.containerId = matched.id; + row.image = matched.image; + usedContainerIds.add(matched.id); + } + } - return { - project: route.projectName, - domains: route.domains, - upstream: route.upstream, - status: statusText, - state, - displayStatus, - container: matchedContainer ? matchedContainer.name.replace(/^\//, "") : undefined, - containerId: matchedContainer?.id, - }; - }); + // Инфраструктурные и фоновые сервисы: контейнеры есть, маршрута в шлюзе нет. + for (const c of appContainers) { + if (usedContainerIds.has(c.id)) continue; + if (!c.pegasProjectName && !c.composeProject) continue; + const proj = c.pegasProjectName || c.composeProject || "unknown"; + const svc = c.pegasService || c.composeService || c.name.replace(/^\//, ""); + const env = c.pegasScenario || ""; + const key = `${proj}::${svc}::${env}`; + if (rowsByKey.has(key)) continue; + rowsByKey.set(key, { + project: proj, + service: svc, + env, + driver: "docker", + endpoint: "", + target: c.image, + gateway: "direct", + health: "", + state: c.state || "unknown", + resources: "-", + domains: c.pegasDomains ? c.pegasDomains.split(",").map((d) => d.trim()).filter(Boolean) : [], + container: c.name.replace(/^\//, ""), + containerId: c.id, + image: c.image, + current: currentProject === proj, + }); + } - items.sort((a, b) => a.project.localeCompare(b.project)); + const rows = [...rowsByKey.values()]; + + // Память контейнеров — одним вызовом на все строки. + const containerIds = rows.map((r) => r.containerId).filter(Boolean) as string[]; + const memById = options.skipProbes ? {} : await getContainerMemory(containerIds); + + // Реальное состояние: host-порты опрашиваются параллельно. + const probes = rows.map(async (row) => { + const container = row.containerId + ? appContainers.find((c) => c.id === row.containerId) + : undefined; + + if (row.driver === "bun" || !container) { + const probePort = row.port ?? (Number(row.target.split(":")[1]) || undefined); + if (row.driver === "bun" && probePort && !options.skipProbes) { + const listening = await probeTcpPort(probePort); + row.state = listening ? "listening" : "offline"; + row.health = listening ? "🟢 Listening" : "🔴 Offline"; + if (!listening) { + row.issue = `маршрут шлюза активен, но на 127.0.0.1:${probePort} ничего не слушает (шлюз ответит 502)`; + } + } else if (row.driver === "bun") { + row.state = "unknown"; + row.health = "⚪ Unknown"; + } else { + const { state, statusText } = formatDockerStatus(undefined, row.target); + row.state = state; + row.health = container ? statusText : state === "host" ? "💻 Host" : "⚪ Not started"; + if (!container && state === "stopped") { + row.issue = "контейнер не найден: сервис не запущен или принадлежит другому checkout"; + } + } + return; + } - const matchedContainerIds = new Set(items.map((i) => i.containerId).filter(Boolean)); - const infraContainers = containers.filter((c) => { - if (c.name === "pegas-gateway" || c.name === "/pegas-gateway") return false; - if (matchedContainerIds.has(c.id)) return false; - return Boolean(c.pegasProject || c.composeProject) && c.state === "running"; + const { state, statusText } = formatDockerStatus(container, row.target); + row.state = state; + row.health = state === "running" + ? (statusText.includes("healthy") ? "🟢 Running (healthy)" + : statusText.includes("unhealthy") ? "🔴 Running (unhealthy)" + : statusText.includes("starting") ? "🟡 Starting" + : `🟢 Running (${formatUptime(container.runningFor)})`) + : `🔴 ${statusText}`; + row.resources = `${formatMemUsage(memById[container.id])} (${formatUptime(container.runningFor)})`; + if (state === "running" && statusText.includes("unhealthy")) { + row.issue = "healthcheck контейнера не проходит"; + } else if (state !== "running") { + row.issue = `контейнер в состоянии "${state}"`; + } }); + await Promise.all(probes); + + // Эндпоинты: фактические адреса, а не выдуманные домены (issue #30). + for (const row of rows) { + const container = row.containerId ? appContainers.find((c) => c.id === row.containerId) : undefined; + if (row.domains.length > 0) { + // Веб-маршрут: домен шлюза. Локальные dev-домены — без замка Let's Encrypt. + const isLocal = row.domains[0].endsWith(".localhost") || row.domains[0] === "localhost"; + row.endpoint = `${isLocal ? "🔧" : "🔒"} ${row.domains[0]}${row.domains.length > 1 ? ` +${row.domains.length - 1}` : ""}`; + } else if (container) { + row.endpoint = resolveInfraEndpoint(container, row.project, row.service, firstHostPort(container.ports), startDir); + } else if (row.driver === "bun") { + const port = row.port ?? Number(row.target.split(":")[1]); + row.endpoint = port > 0 ? `localhost:${port}` : "(host)"; + } else { + row.endpoint = "(internal)"; + } + } + + rows.sort((a, b) => + a.project.localeCompare(b.project) || a.env.localeCompare(b.env) || a.service.localeCompare(b.service) + ); if (isJson) { - const cleanProjects = items.map(({ displayStatus, ...rest }) => rest); - const cleanInfra = infraContainers.map((c) => { - const proj = - c.pegasProject || - c.composeProject?.replace(/-[a-f0-9]{12}-development|-development|-production|-stage/g, "") || - "unknown"; - const svc = c.pegasService || c.composeService || c.name.replace(/^\//, ""); - const { statusText } = formatDockerStatus(c); - const cleanPorts = c.ports - ? c.ports - .split(",") - .map((p) => p.trim().replace(/0\.0\.0\.0:|\[::\]:/g, "").replace(/\/tcp|\/udp/g, "")) - .filter(Boolean) - .filter((v, i, a) => a.indexOf(v) === i) - .join(", ") - : "-"; - const endpoint = resolveInfraEndpoint(c, proj, svc, cleanPorts); + const cleanProjects: ProjectDashboardItem[] = routes.map((route) => { + const matched = matchContainerForRoute(route.upstream, route.projectName, appContainers, { + service: route.meta?.service, + env: route.meta?.env, + }); + const { displayStatus, state, statusText } = formatDockerStatus(matched, route.upstream); return { - project: proj, - service: svc, - endpoint, - image: c.image, - ports: c.ports, + project: route.projectName, + domains: route.domains, + upstream: route.upstream, status: statusText, - state: c.state, - containerId: c.id, - containerName: c.name.replace(/^\//, ""), + state, + displayStatus, + container: matched ? matched.name.replace(/^\//, "") : undefined, + containerId: matched?.id, }; }); + console.log( JSON.stringify( { @@ -456,8 +911,33 @@ export async function handleStatus(options: { gatewayDir?: string; json?: boolea gatewayDir, activeRoutesCount: routes.length, }, + // Единый дашборд (issue #30): сервис — единица вывода, не домен. + services: rows.map((r) => ({ + project: r.project, + service: r.service, + env: r.env, + driver: r.driver, + endpoint: r.endpoint, + target: r.target, + gateway: r.gateway, + health: r.health.replace(/\x1b\[[0-9;]*m/g, ""), + state: r.state, + resources: r.resources, + domains: r.domains, + current: r.current, + container: r.container, + containerId: r.containerId, + image: r.image, + ...(r.issue ? { issue: r.issue } : {}), + })), + // Обратная совместимость: прежняя форма списка маршрутов. projects: cleanProjects, - infrastructure: cleanInfra, + issues: rows.filter((r) => r.issue).map((r) => ({ + project: r.project, + service: r.service, + env: r.env, + message: r.issue, + })), }, null, 2 @@ -476,7 +956,7 @@ export async function handleStatus(options: { gatewayDir?: string; json?: boolea // предупреждения о красном замке (расхождение возникает при пересоздании volume). let tlsLine = " Local CA Trust: ⚠ unknown (gateway PKI not reachable)"; try { - const { readGatewayRootCert, getCaPaths, isCaTrusted } = await import("../ca.js"); + const { readGatewayRootCert, isCaTrusted } = await import("../ca.js"); const gwRoot = await readGatewayRootCert(); if (gwRoot !== null) { const trusted = await isCaTrusted(process.platform, undefined, { gatewayRoot: gwRoot }); @@ -488,43 +968,44 @@ export async function handleStatus(options: { gatewayDir?: string; json?: boolea console.log(tlsLine); console.log(` Active Routes: ${routes.length} domain(s)\n`); - if (items.length > 0) { - console.log(`🚀 \x1b[1mActive Web Routes (HTTP/HTTPS):\x1b[0m\n`); - const headers = ["PROJECT", "DOMAINS", "UPSTREAM", "STATUS"]; - const rows = items.map((item) => [ - item.project, - item.domains.join(", "), - item.upstream, - item.displayStatus, + if (rows.length > 0) { + console.log(`🚀 \x1b[1mPegas Services Dashboard\x1b[0m\n`); + const headers = ["", "PROJECT", "SERVICE", "ENV", "DRIVER", "ENDPOINT", "TARGET", "GATEWAY", "HEALTH", "MEM / UP"]; + const gatewayLabel: Record = { + linked: "🔗 Linked", + routed: "📦 Routed", + direct: "➖ Direct", + none: "—", + }; + const tableRows = rows.map((r) => [ + r.current ? "★" : "", + r.project, + r.service, + shortEnv(r.env), + r.driver, + r.endpoint, + r.target, + gatewayLabel[r.gateway], + r.health, + r.resources, ]); - console.log(renderTable({ headers, rows })); - console.log(`\nTotal: ${items.length} active route${items.length === 1 ? "" : "s"}\n`); + console.log(renderTable({ headers, rows: tableRows })); + console.log(`\nTotal: ${rows.length} service(s)\n`); + + const issues = rows.filter((r) => r.issue); + if (issues.length > 0) { + console.log(`⚠️ Обнаружено проблем: ${issues.length}`); + for (const r of issues) { + console.log(` • ${r.project} [${r.service}] (${shortEnv(r.env)}): ${r.issue}`); + if (r.driver === "bun") { + console.log(` ➔ Запустите процесс сервиса в каталоге проекта (host-драйвер не супервизируется Pegas).`); + } else { + console.log(` ➔ Проверьте логи: pegas logs ${shortEnv(r.env) === "dev" ? "development" : r.env} ${r.service}`); + } + } + console.log(""); + } } else { - console.log("ℹ️ No web routes currently deployed to Pegas gateway.\n"); - } - - if (infraContainers.length > 0) { - console.log(`📦 \x1b[1mActive Infrastructure & Background Services:\x1b[0m\n`); - const infraHeaders = ["PROJECT", "SERVICE", "ENDPOINT / HOST", "IMAGE", "PORTS", "STATUS"]; - const infraRows = infraContainers.map((c) => { - const proj = - c.pegasProject || - c.composeProject?.replace(/-[a-f0-9]{12}-development|-development|-production|-stage/g, "") || - "unknown"; - const svc = c.pegasService || c.composeService || c.name.replace(/^\//, ""); - const { displayStatus } = formatDockerStatus(c); - const cleanPorts = c.ports - ? c.ports - .split(",") - .map((p) => p.trim().replace(/0\.0\.0\.0:|\[::\]:/g, "").replace(/\/tcp|\/udp/g, "")) - .filter(Boolean) - .filter((v, i, a) => a.indexOf(v) === i) - .join(", ") - : "-"; - const endpoint = resolveInfraEndpoint(c, proj, svc, cleanPorts); - return [proj, svc, endpoint, c.image, cleanPorts || "-", displayStatus]; - }); - console.log(renderTable({ headers: infraHeaders, rows: infraRows })); - console.log(`\nTotal: ${infraContainers.length} active service${infraContainers.length === 1 ? "" : "s"}\n`); + console.log("ℹ️ Активных сервисов Pegas не найдено.\n"); } } diff --git a/src/contract.ts b/src/contract.ts index d80ff50..fe56066 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -8,6 +8,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; import { PegasContract, ServiceContract, ScenarioMap } from "./types.js"; +import { classifySharedImage } from "./shared-infra.js"; export const CONTRACT_FILENAME = "pegas.json"; @@ -20,10 +21,19 @@ const SCENARIO_NAME_REGEX = /^[a-z0-9-]+$/; /** Паттерн имени сервиса (как compose service name). */ const SERVICE_NAME_REGEX = /^[a-z0-9][a-z0-9_-]*$/; +/** + * Паттерн имени арендатора разделяемого пула (issue #29). + * Ограничен алфавитом SQL-идентификаторов: значение становится именем базы и + * роли PostgreSQL, а также частью имени compose-сервиса, поэтому только нижний + * регистр, цифры и подчёркивание. + */ +const SHARED_TENANT_NAME_REGEX = /^[a-z_][a-z0-9_]*$/; + /** Сценарные поля сервиса: каждое обязано быть картой по scenarios. */ const SCENARIO_FIELDS = [ "runtime", "image", "build", "command", "port", "ports", "domains", "publicHost", "volumes", "dependsOn", "healthcheck", "cors", + "scope", "shared", ] as const; export interface ContractValidationIssue { @@ -392,6 +402,66 @@ export function validatePegasContract(value: unknown): ContractValidationIssue[] add(`${prefix} healthcheck`, 'Для docker-сервиса healthcheck — объект { test: [...] } (compose-формат) или null.'); } } + + // scope / shared (issue #29): разделяемый инфраструктурный пул + const scope = (svc.scope as ScenarioMap | undefined)?.[sc]; + const shared = (svc.shared as ScenarioMap | undefined)?.[sc]; + + if (scope !== undefined && scope !== "isolated" && scope !== "shared") { + add(`${prefix} scope`, 'scope: "isolated" | "shared" — других значений нет.'); + } + + if (scope === "shared") { + if (isHost) { + add(`${prefix} scope`, 'scope: "shared" несовместим с driver: "bun" — пул это контейнер, host-процесс разделить нельзя.'); + } + const image = (svc.image as ScenarioMap | undefined)?.[sc]; + const build = (svc.build as ScenarioMap | undefined)?.[sc]; + if (build) { + add(`${prefix} build`, 'build несовместим с scope: "shared" — содержимое собранного образа принадлежит проекту, разделять его нельзя.'); + } + if (!image) { + add(`${prefix} image`, 'scope: "shared" требует публичный образ инфраструктуры (postgres:18-alpine, redis:7-alpine).'); + } else if (!classifySharedImage(image)) { + add(`${prefix} image`, `Образ "${image}" не является поддерживаемой разделяемой инфраструктурой. Поддерживаются postgres, pgvector, timescaledb, postgis, redis, valkey; для прочего используйте scope: "isolated".`); + } + if (typeof (svc.port as ScenarioMap | undefined)?.[sc] !== "number") { + add(`${prefix} port`, 'scope: "shared" требует port: по нему формируется адрес пула в сети pegas-network.'); + } + + if (shared === undefined || shared === null) { + add(`${prefix} shared`, 'scope: "shared" требует объект { name } — имя базы/роли (postgres) или префикса ключей (redis).'); + } else if (typeof shared !== "object" || Array.isArray(shared)) { + add(`${prefix} shared`, "shared — объект { name, secret? } (или null для изолированного сценария)."); + } else { + const tenantName = (shared as { name?: unknown }).name; + if (typeof tenantName !== "string" || !SHARED_TENANT_NAME_REGEX.test(tenantName)) { + add(`${prefix} shared.name`, "Имя арендатора: [a-z_][a-z0-9_]* — оно становится именем базы и роли PostgreSQL, поэтому другие символы недопустимы."); + } + const secretName = (shared as { secret?: unknown }).secret; + if (secretName !== undefined) { + if (typeof secretName !== "string" || !/^[A-Z0-9_]+$/.test(secretName)) { + add(`${prefix} shared.secret`, "secret — имя секретного ключа [A-Z0-9_]+ (значение берётся из .env)."); + } else if (!Array.isArray(svc.secrets) || !svc.secrets.includes(secretName)) { + add(`${prefix} shared.secret`, `Секрет "${secretName}" не объявлен в services.${name}.secrets: значение не будет доступно при подготовке арендатора.`); + } + } + } + + // Пул поднимается с готовым томом: собственные тома сервиса привели бы к + // двум источникам данных, поэтому в разделяемом сценарии они запрещены. + const volumes = (svc.volumes as ScenarioMap | undefined)?.[sc]; + if (Array.isArray(volumes) && volumes.length > 0) { + add(`${prefix} volumes`, 'volumes несовместимы с scope: "shared" — данные хранятся в томе пула, который pegas создаёт и сохраняет сам.'); + } + // Пул живёт вне compose-проекта: dependsOn от него не сработает. + const depends = (svc.dependsOn as ScenarioMap> | undefined)?.[sc]; + if (depends && Object.keys(depends).length > 0) { + add(`${prefix} dependsOn`, 'dependsOn несовместим с scope: "shared" — pegas поднимает пул до compose-проекта и ожидает его готовности.'); + } + } else if (shared !== undefined && shared !== null) { + add(`${prefix} shared`, `shared без scope: "shared" в этом сценарии — уберите поле или объявите scope: "shared".`); + } } } diff --git a/src/contract.v3.test.ts b/src/contract.v3.test.ts index 0d322ea..3f39a8b 100644 --- a/src/contract.v3.test.ts +++ b/src/contract.v3.test.ts @@ -352,3 +352,102 @@ describe("v3: publicHost — строка, не объект", () => { expect(issues.some((i: any) => i.path.includes("publicHost") && i.message.includes("DNS-имя"))).toBe(true); }); }); + +describe("v3 валидатор: scope/shared — разделяемый пул (issue #29)", () => { + function sharedRedis(): PegasContract { + const c = validContract(); + (c.services.redis as any).scope = { development: "shared", stage: "isolated", production: "isolated" }; + (c.services.redis as any).shared = { development: { name: "myapp_cache" }, stage: null, production: null }; + return c; + } + + it("валидный shared redis — без issues", () => { + expect(validatePegasContract(sharedRedis())).toEqual([]); + }); + + it("валидный shared postgres с объявленным секретом — без issues", () => { + const c = validContract(); + (c.services as any).db = { + runtime: { development: { driver: "docker" }, stage: { driver: "docker" }, production: { driver: "docker" } }, + image: { development: "postgres:18-alpine", stage: "postgres:18-alpine", production: "postgres:18-alpine" }, + port: { development: 5432, stage: 5432, production: 5432 }, + scope: { development: "shared", stage: "isolated", production: "isolated" }, + shared: { development: { name: "myapp_db", secret: "DB_PASSWORD" }, stage: null, production: null }, + secrets: ["DB_PASSWORD"], + }; + expect(validatePegasContract(c)).toEqual([]); + }); + + it("неизвестное значение scope — ошибка", () => { + const c = sharedRedis(); + (c.services.redis as any).scope.development = "cluster"; + expect(validatePegasContract(c).some((i) => i.message.includes("других значений нет"))).toBe(true); + }); + + it("shared без shared-объекта — ошибка", () => { + const c = sharedRedis(); + (c.services.redis as any).shared = { development: null, stage: null, production: null }; + expect(validatePegasContract(c).some((i) => i.message.includes("требует объект"))).toBe(true); + }); + + it("shared без scope — ошибка", () => { + const c = validContract(); + (c.services.redis as any).shared = { development: { name: "myapp_cache" }, stage: null, production: null }; + expect(validatePegasContract(c).some((i) => i.message.includes("без scope"))).toBe(true); + }); + + it("имя арендатора вне [a-z_][a-z0-9_]* — ошибка", () => { + const c = sharedRedis(); + (c.services.redis as any).shared.development.name = "My-App"; + expect(validatePegasContract(c).some((i) => i.message.includes("Имя арендатора"))).toBe(true); + }); + + it("shared с bun-драйвером — ошибка", () => { + const c = sharedRedis(); + (c.services.redis.runtime as any).development = { driver: "bun" }; + expect(validatePegasContract(c).some((i) => i.message.includes('несовместим с driver: "bun"'))).toBe(true); + }); + + it("shared с build — ошибка", () => { + const c = sharedRedis(); + (c.services.redis as any).build = { development: { context: "." }, stage: null, production: null }; + expect(validatePegasContract(c).some((i) => i.message.includes("build несовместим"))).toBe(true); + }); + + it("shared с неподдерживаемым образом и без образа — ошибка", () => { + const c = sharedRedis(); + (c.services.redis.image as any).development = "nginx:1"; + expect(validatePegasContract(c).some((i) => i.message.includes("не является поддерживаемой разделяемой инфраструктурой"))).toBe(true); + const noImage = sharedRedis(); + delete (noImage.services.redis.image as any).development; + expect(validatePegasContract(noImage).some((i) => i.message.includes("требует публичный образ"))).toBe(true); + }); + + it("shared без port — ошибка", () => { + const c = sharedRedis(); + delete (c.services.redis.port as any).development; + expect(validatePegasContract(c).some((i) => i.message.includes("требует port"))).toBe(true); + }); + + it("shared с volumes и dependsOn — ошибка", () => { + const c = sharedRedis(); + (c.services.redis as any).volumes = { development: ["data:/data"], stage: [], production: [] }; + expect(validatePegasContract(c).some((i) => i.message.includes("volumes несовместимы"))).toBe(true); + const d = sharedRedis(); + (d.services.redis as any).dependsOn = { development: { web: { condition: "service_started" } }, stage: {}, production: {} }; + expect(validatePegasContract(d).some((i) => i.message.includes("dependsOn несовместим"))).toBe(true); + }); + + it("shared.secret: необъявленный и плохой формат — ошибка, объявленный — ок", () => { + const undeclared = sharedRedis(); + (undeclared.services.redis as any).shared.development.secret = "REDIS_PASSWORD"; + expect(validatePegasContract(undeclared).some((i) => i.message.includes("не объявлен в services.redis.secrets"))).toBe(true); + const badFormat = sharedRedis(); + (badFormat.services.redis as any).shared.development.secret = "lowercase"; + expect(validatePegasContract(badFormat).some((i) => i.message.includes("имя секретного ключа"))).toBe(true); + const ok = sharedRedis(); + (ok.services.redis as any).shared.development.secret = "REDIS_PASSWORD"; + (ok.services.redis as any).secrets = ["REDIS_PASSWORD"]; + expect(validatePegasContract(ok)).toEqual([]); + }); +}); diff --git a/src/generator.ts b/src/generator.ts index 6f4d164..94c3489 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -1,4 +1,5 @@ import { RouteSnippetOptions } from "./types.js"; +import { serializeRouteMeta } from "./route-meta.js"; export function buildCorsOriginRegex(domains: string[] = [], customOrigins?: string[]): string { const parts: string[] = []; @@ -71,6 +72,7 @@ export function generateCaddyfileSnippet(options: RouteSnippetOptions): string { routes, cors, corsOrigins, + meta, } = options; const lines: string[] = []; @@ -83,6 +85,14 @@ export function generateCaddyfileSnippet(options: RouteSnippetOptions): string { lines.push(``); } + // Метаданные маршрута (issue #30) — комментарий непосредственно перед основным + // блоком: `pegas status` читает из него сервис, сценарий и драйвер. Ставится + // ПОСЛЕ www-редиректа, чтобы комментарий принадлежал блоку с reverse_proxy. + if (meta) { + const metaLine = serializeRouteMeta(meta); + if (metaLine) lines.push(metaLine); + } + // Main domain block lines.push(`${domain} {`); diff --git a/src/help.ts b/src/help.ts index 17efc50..c4922e3 100644 --- a/src/help.ts +++ b/src/help.ts @@ -128,7 +128,7 @@ export const COMMANDS: CliCommandDef[] = [ }, { name: "logs", - summary: "Stream or view container logs for any environment (development, stage, production)", + summary: "Stream or view container logs for development, stage, and production environments", usage: "logs [env] [service] [options]", options: [ { @@ -143,7 +143,7 @@ export const COMMANDS: CliCommandDef[] = [ type: "boolean", short: "f", default: false, - description: "Follow log output (stream real-time changes)", + description: "Follow log output in real time", }, { name: "tail", @@ -267,8 +267,16 @@ export const COMMANDS: CliCommandDef[] = [ }, { name: "status", - summary: "Check gateway health and active project routes", + summary: "Unified dashboard: gateway health plus every Pegas service with real process health", usage: "status [options]", + options: [ + { + name: "cwd", + type: "string", + placeholder: "", + description: "Project directory whose rows are marked with ★ in the dashboard", + }, + ], examples: ["$ pegas status", "$ pegas status --json"], }, { diff --git a/src/index.ts b/src/index.ts index 798158c..e973679 100644 --- a/src/index.ts +++ b/src/index.ts @@ -250,6 +250,7 @@ export async function main(argv = process.argv.slice(2), options: MainOptions = await dispatch.handleStatus({ gatewayDir: values["gateway-dir"] as string | undefined, json: values.json as boolean | undefined, + cwd: values.cwd as string | undefined, }); break; } diff --git a/src/nextjs-audit.test.ts b/src/nextjs-audit.test.ts new file mode 100644 index 0000000..59bfc82 --- /dev/null +++ b/src/nextjs-audit.test.ts @@ -0,0 +1,861 @@ +import { describe, it, expect } from "bun:test"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + auditNextjsDevOrigins, + builtinDevOrigins, + blocksUnlistedDevOrigin, + isDevOriginCovered, + matchWildcardDomain, + matchWildcardDomainLegacy, + parseAllowedDevOrigins, + recommendedDevOrigins, + stripComments, + supportsAllowedDevOrigins, + NEXTJS_GUIDE_PATH, + type NextjsAuditFinding, +} from "./nextjs-audit.js"; + +/** + * Тесты аудита allowedDevOrigins (issue #28). + * + * Домены двух уровней (`..localhost`) — основной сценарий: + * именно их Next.js не пропускает. Домены одного уровня (`.localhost`) + * в Next.js 15.3–16.2 покрыты встроенным списком `*.localhost`, поэтому + * предупреждение на них не выдаётся — это проверяется отдельно. + */ + +/** Временный каталог проекта с package.json и (опционально) конфигом Next. */ +async function makeProject(opts: { + next?: string | null; + installedNext?: string | null; + configName?: string; + configText?: string; + devDeps?: boolean; +}): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pegas-nextjs-")); + if (opts.next !== null) { + const pkg: Record = { name: "app", version: "1.0.0" }; + if (opts.devDeps) pkg.devDependencies = { next: opts.next }; + else pkg.dependencies = { next: opts.next }; + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify(pkg, null, 2)); + } + if (opts.installedNext) { + const nm = path.join(dir, "node_modules", "next"); + await fs.mkdir(nm, { recursive: true }); + await fs.writeFile(path.join(nm, "package.json"), JSON.stringify({ name: "next", version: opts.installedNext })); + } + if (opts.configName) { + await fs.writeFile(path.join(dir, opts.configName), opts.configText ?? ""); + } + return dir; +} + +async function cleanup(dir: string): Promise { + await fs.rm(dir, { recursive: true, force: true }); +} + +/** Два домена: корень зоны и поддомен сервиса — типичная пара Pegas. */ +const DEV_DOMAINS = ["my-app.localhost", "api.my-app.localhost"]; + +describe("nextjs-audit: обнаружение Next.js", () => { + it("нет package.json → аудит не применяется, без предупреждения", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pegas-nextjs-empty-")); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.nextjsDetected).toBe(false); + expect(r.status).toBe("not-detected"); + expect(r.warning).toBeNull(); + expect(r.hint).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("битый package.json → не падает, аудит не применяется", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pegas-nextjs-broken-")); + try { + await fs.writeFile(path.join(dir, "package.json"), "{ это не json"); + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.nextjsDetected).toBe(false); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("package.json без next → not-detected", async () => { + const dir = await makeProject({ next: null }); + try { + await fs.writeFile(path.join(dir, "package.json"), JSON.stringify({ dependencies: { express: "^5" } })); + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.nextjsDetected).toBe(false); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("next в devDependencies тоже обнаруживается", async () => { + const dir = await makeProject({ next: "15.5.0", devDeps: true, configName: "next.config.ts", configText: "export default {}" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.nextjsDetected).toBe(true); + expect(r.status).toBe("missing-option"); + expect(r.warning).not.toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("версия из node_modules важнее объявленного диапазона", async () => { + // Диапазон ^14.2.0 сам по себе неоднозначен: установленная 14.2.20 опцию не знает. + const dir = await makeProject({ next: "^14.2.0", installedNext: "14.2.20" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.nextVersion).toBe("14.2.20"); + expect(r.status).toBe("unsupported"); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); +}); + +describe("nextjs-audit: версии Next.js", () => { + it("supportsAllowedDevOrigins: границы по исходникам Next.js", () => { + expect(supportsAllowedDevOrigins("15.3.0")).toBe(true); + expect(supportsAllowedDevOrigins("15.2.9")).toBe(false); + expect(supportsAllowedDevOrigins("14.2.30")).toBe(true); + expect(supportsAllowedDevOrigins("14.2.29")).toBe(false); + expect(supportsAllowedDevOrigins("16.4.5")).toBe(true); + expect(supportsAllowedDevOrigins("13.5.6")).toBe(false); + // Неизвестная версия трактуется как поддерживающая (лучше предупредить). + expect(supportsAllowedDevOrigins("latest")).toBe(true); + expect(supportsAllowedDevOrigins(null)).toBe(true); + }); + + it("unsupported: старая версия Next.js — без предупреждения и без совета", async () => { + const dir = await makeProject({ next: "15.1.0" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("unsupported"); + expect(r.warning).toBeNull(); + expect(r.hint).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("builtinDevOrigins: *.localhost до 16.2, **.localhost с 16.3", () => { + expect(builtinDevOrigins("15.5.0")).toEqual(["*.localhost", "localhost"]); + expect(builtinDevOrigins("16.2.0")).toEqual(["*.localhost", "localhost"]); + expect(builtinDevOrigins("16.3.0")).toEqual(["**.localhost", "localhost"]); + expect(builtinDevOrigins("14.2.33")).toEqual(["*.localhost", "localhost"]); + expect(builtinDevOrigins(null)).toEqual(["*.localhost", "localhost"]); + }); + + it("blocksUnlistedDevOrigin: warn-режим до 16.2, блокировка с 16.2", () => { + expect(blocksUnlistedDevOrigin("15.5.0", false)).toBe(false); // опция не задана → только предупреждение + expect(blocksUnlistedDevOrigin("15.5.0", true)).toBe(true); // опция задана → блокировка + expect(blocksUnlistedDevOrigin("14.2.33", false)).toBe(false); + expect(blocksUnlistedDevOrigin("16.2.0", false)).toBe(true); + expect(blocksUnlistedDevOrigin("16.4.5", true)).toBe(true); + }); + + it("Next.js 16.3+: встроенный **.localhost уже покрывает домены — предупреждения нет", async () => { + const dir = await makeProject({ next: "16.3.0", configName: "next.config.ts", configText: "export default { reactStrictMode: true }" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("ok"); + expect(r.coversPegasOrigins).toBe(true); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("одноуровневый .localhost на 15.5 покрыт встроенным *.localhost — молчим", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.ts", configText: "export default {}" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["my-app.localhost"] }); + expect(r.status).toBe("ok"); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("одноуровневый .localhost на 14.2.33 НЕ покрыт: legacy-матчер не понимает *.localhost", async () => { + const dir = await makeProject({ next: "14.2.33", configName: "next.config.js", configText: "module.exports = {}" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["my-app.localhost"] }); + expect(r.status).toBe("missing-option"); + // На ветке 14.2 универсальный шаблон не работает: рецепт — точное имя + **..localhost. + expect(r.recommendedOrigins).toEqual(["my-app.localhost", "**.my-app.localhost"]); + expect(r.warning).toContain("'**.my-app.localhost'"); + // Опция не задана, версия до 16.2 → Next.js пока только предупреждает, не блокирует. + expect(r.warning).toContain("печатает предупреждение"); + expect(r.warning).not.toContain("403"); + } finally { + await cleanup(dir); + } + }); +}); + +describe("nextjs-audit: сопоставление шаблонов (семантика Next.js)", () => { + it("* — ровно одна метка, ** — одна и более (15.3+)", () => { + expect(matchWildcardDomain("my-app.localhost", "*.localhost")).toBe(true); + expect(matchWildcardDomain("api.my-app.localhost", "*.localhost")).toBe(false); + expect(matchWildcardDomain("api.my-app.localhost", "**.localhost")).toBe(true); + expect(matchWildcardDomain("a.b.my-app.localhost", "**.localhost")).toBe(true); + expect(matchWildcardDomain("localhost", "*.localhost")).toBe(false); + expect(matchWildcardDomain("api.my-app.localhost", "**.my-app.localhost")).toBe(true); + expect(matchWildcardDomain("my-app.localhost", "**.my-app.localhost")).toBe(false); + // Частичная подстановка внутри метки не поддерживается. + expect(matchWildcardDomain("team.eu.example.com", "*.example.com")).toBe(false); + expect(matchWildcardDomain("anything", "*")).toBe(false); + expect(matchWildcardDomain("a.b.c", "**")).toBe(false); + expect(matchWildcardDomain("a.b.c", "c.**")).toBe(false); + }); + + it("legacy-матчер ветки 14.2: подстановка в двух последних метках запрещена", () => { + expect(matchWildcardDomainLegacy("my-app.localhost", "*.localhost")).toBe(false); + expect(matchWildcardDomainLegacy("api.my-app.localhost", "**.localhost")).toBe(false); + expect(matchWildcardDomainLegacy("api.my-app.localhost", "**.my-app.localhost")).toBe(true); + expect(matchWildcardDomainLegacy("my-app.localhost", "my-app.localhost")).toBe(true); + }); + + it("isDevOriginCovered: localhost разрешён всегда, регистр не важен", () => { + expect(isDevOriginCovered("localhost", [])).toBe(true); + expect(isDevOriginCovered("MY-APP.localhost", ["my-app.localhost"])).toBe(true); + expect(isDevOriginCovered("api.my-app.localhost", ["example.com"])).toBe(false); + expect(isDevOriginCovered("api.my-app.localhost", ["**.my-app.localhost"], true)).toBe(true); + expect(isDevOriginCovered("api.my-app.localhost", ["", null as unknown as string])).toBe(false); + }); + + it("recommendedDevOrigins: корень зоны + рекурсивный шаблон на каждый проект", () => { + expect(recommendedDevOrigins(DEV_DOMAINS)).toEqual(["my-app.localhost", "**.my-app.localhost"]); + expect(recommendedDevOrigins(["a.localhost", "b.localhost"])).toEqual([ + "a.localhost", + "**.a.localhost", + "b.localhost", + "**.b.localhost", + ]); + expect(recommendedDevOrigins(["example.com"])).toEqual(["**.localhost"]); + expect(recommendedDevOrigins([])).toEqual(["**.localhost"]); + // Значения с портом и схемой нормализуются до hostname. + expect(recommendedDevOrigins(["https://my-app.localhost:443/"])).toEqual(["my-app.localhost", "**.my-app.localhost"]); + }); +}); + +describe("nextjs-audit: разбор конфига (текстовая эвристика)", () => { + it("stripComments: строки сохраняются, комментарии и regex-литералы убираются", () => { + const src = [ + "const re = /https?:\\/\\//; // это комментарий со словом allowedDevOrigins", + "/* блочный allowedDevOrigins */", + 'const s = "строка с // внутри и allowedDevOrigins";', + "module.exports = { allowedDevOrigins: ['a.localhost'] };", + ].join("\n"); + const out = stripComments(src); + expect(out).toContain('const s = "строка с // внутри и allowedDevOrigins";'); + expect(out).toContain("module.exports = { allowedDevOrigins: ['a.localhost'] };"); + expect(out).not.toContain("это комментарий"); + expect(out).not.toContain("блочный"); + expect(out).not.toContain("/https?:"); + }); + + it("parseAllowedDevOrigins: плоский массив строк во всех кавычках", () => { + expect(parseAllowedDevOrigins("module.exports = { allowedDevOrigins: ['a.localhost', \"b.localhost\"] }")).toEqual({ + found: true, + entries: ["a.localhost", "b.localhost"], + complete: true, + }); + expect(parseAllowedDevOrigins("export default { allowedDevOrigins: [`a.localhost`] }")).toEqual({ + found: true, + entries: ["a.localhost"], + complete: true, + }); + }); + + it("parseAllowedDevOrigins: многострочный массив и TS-аннотации", () => { + const src = ` + import type { NextConfig } from "next"; + interface Base { allowedDevOrigins?: string[] } + const nextConfig: NextConfig = { + reactStrictMode: true, + allowedDevOrigins: [ + "my-app.localhost", + '**.my-app.localhost', + ], + }; + export default nextConfig; + `; + const parsed = parseAllowedDevOrigins(src); + expect(parsed.found).toBe(true); + expect(parsed.complete).toBe(true); + expect(parsed.entries).toEqual(["my-app.localhost", "**.my-app.localhost"]); + }); + + it("parseAllowedDevOrigins: defineNextConfig, JSON-ключ и пост-присваивание", () => { + expect(parseAllowedDevOrigins("export default defineNextConfig({ allowedDevOrigins: ['a.localhost'] })").entries).toEqual(["a.localhost"]); + expect(parseAllowedDevOrigins('module.exports = { "allowedDevOrigins": ["a.localhost"] }').entries).toEqual(["a.localhost"]); + const post = "const c = {};\nc.allowedDevOrigins = ['a.localhost'];\nmodule.exports = c;"; + expect(parseAllowedDevOrigins(post).entries).toEqual(["a.localhost"]); + }); + + it("parseAllowedDevOrigins: слово внутри строки или комментария ключом не считается", () => { + expect(parseAllowedDevOrigins('const note = "добавьте allowedDevOrigins: [\'*\'] в конфиг";').found).toBe(false); + expect(parseAllowedDevOrigins("// allowedDevOrigins: ['a.localhost']\nmodule.exports = {};").found).toBe(false); + expect(parseAllowedDevOrigins("/* allowedDevOrigins: ['a.localhost'] */\nexport default {};").found).toBe(false); + // Чтение чужого свойства без присваивания — не конфиг этого файла. + expect(parseAllowedDevOrigins("const x = { other: base.allowedDevOrigins };").found).toBe(false); + }); + + it("parseAllowedDevOrigins: вычисляемые значения помечаются неполными", () => { + const variable = parseAllowedDevOrigins("module.exports = { allowedDevOrigins: devOrigins }"); + expect(variable.found).toBe(true); + expect(variable.complete).toBe(false); + + const spread = parseAllowedDevOrigins("module.exports = { allowedDevOrigins: ['a.localhost', ...extra] }"); + expect(spread.complete).toBe(false); + expect(spread.entries).toEqual(["a.localhost"]); + + const call = parseAllowedDevOrigins("module.exports = { allowedDevOrigins: buildOrigins(env) }"); + expect(call.complete).toBe(false); + + const interpolated = parseAllowedDevOrigins("module.exports = { allowedDevOrigins: [`${project}.localhost`] }"); + expect(interpolated.complete).toBe(false); + + const nested = parseAllowedDevOrigins("module.exports = { allowedDevOrigins: [[ 'a.localhost' ]] }"); + expect(nested.complete).toBe(false); + }); + + it("parseAllowedDevOrigins: одиночная строка вместо массива читается", () => { + expect(parseAllowedDevOrigins("module.exports = { allowedDevOrigins: 'my-app.localhost' }")).toEqual({ + found: true, + entries: ["my-app.localhost"], + complete: true, + }); + }); + + it("parseAllowedDevOrigins: вычисляемый ключ — неоднозначность, а не отсутствие опции", () => { + const src = 'const K = "allowedDevOrigins";\nmodule.exports = { [K]: ["a.localhost"] };'; + const parsed = parseAllowedDevOrigins(src); + expect(parsed.found).toBe(false); + expect(parsed.ambiguous).toBe(true); + expect(parseAllowedDevOrigins("module.exports = { reactStrictMode: true };").ambiguous).toBe(false); + }); + + it("битый/обрезанный конфиг не роняет разбор", () => { + expect(() => parseAllowedDevOrigins("module.exports = { allowedDevOrigins: ['a.localhost")).not.toThrow(); + const broken = parseAllowedDevOrigins("module.exports = { allowedDevOrigins: ['a.localhost"); + expect(broken.found).toBe(true); + expect(broken.complete).toBe(false); + expect(parseAllowedDevOrigins("").found).toBe(false); + expect(parseAllowedDevOrigins("случайный текст без конфига").found).toBe(false); + }); +}); + +describe("nextjs-audit: результаты по содержимому конфига", () => { + it("Next.js есть, конфига нет → missing-config с предупреждением", async () => { + const dir = await makeProject({ next: "15.5.0" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("missing-config"); + expect(r.configFile).toBeNull(); + expect(r.warning).toContain("next.config"); + expect(r.warning).toContain(NEXTJS_GUIDE_PATH); + expect(r.warning).toContain("api.my-app.localhost"); + // На Next 15.5 встроенный *.localhost покрывает корень зоны — в непокрытых + // остаётся только домен сервиса. + expect(r.uncoveredDomains).toEqual(["api.my-app.localhost"]); + } finally { + await cleanup(dir); + } + }); + + it("конфиг есть, allowedDevOrigins нет → missing-option", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.ts", + configText: 'import type { NextConfig } from "next";\nconst nextConfig: NextConfig = { reactStrictMode: true };\nexport default nextConfig;\n', + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("missing-option"); + expect(r.hasAllowedDevOrigins).toBe(false); + expect(r.configFile).toBe("next.config.ts"); + expect(r.warning).toContain("allowedDevOrigins"); + expect(r.warning).toContain("'my-app.localhost'"); + expect(r.warning).toContain("'**.my-app.localhost'"); + } finally { + await cleanup(dir); + } + }); + + it("битый конфиг (мусор вместо кода) → не падает, даёт предупреждение missing-option", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.mjs", configText: "{{{{ ((( not a config" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("missing-option"); + expect(r.warning).not.toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("недоступный для чтения конфиг → indeterminate с подсказкой, а не ложное обвинение", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = {}" }); + try { + // Снимаем права на чтение: stat видит файл, readFile бросает EACCES. + await fs.chmod(path.join(dir, "next.config.js"), 0o000); + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("indeterminate"); + expect(r.configFile).toBe("next.config.js"); + expect(r.warning).toBeNull(); + expect(r.hint).toContain("не удалось прочитать"); + } finally { + await fs.chmod(path.join(dir, "next.config.js"), 0o644).catch(() => {}); + await cleanup(dir); + } + }); + + it("каталог с именем next.config.js конфигом не считается", async () => { + const dir = await makeProject({ next: "15.5.0" }); + try { + await fs.mkdir(path.join(dir, "next.config.js"), { recursive: true }); + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.configFile).toBeNull(); + expect(r.status).toBe("missing-config"); + } finally { + await cleanup(dir); + } + }); + + const coveredConfigs: Array<{ name: string; file: string; text: string }> = [ + { + name: "next.config.ts / export default / одинарные кавычки", + file: "next.config.ts", + text: 'import type { NextConfig } from "next";\nconst nextConfig: NextConfig = {\n allowedDevOrigins: [\'my-app.localhost\', \'**.my-app.localhost\'],\n};\nexport default nextConfig;\n', + }, + { + name: "next.config.js / module.exports / двойные кавычки", + file: "next.config.js", + text: 'module.exports = {\n allowedDevOrigins: ["my-app.localhost", "**.my-app.localhost"],\n};\n', + }, + { + name: "next.config.mjs / export default / обратные кавычки", + file: "next.config.mjs", + text: "export default {\n allowedDevOrigins: [`my-app.localhost`, `**.my-app.localhost`],\n};\n", + }, + { + name: "next.config.js / defineNextConfig", + file: "next.config.js", + text: "const { defineNextConfig } = require('./lib');\nmodule.exports = defineNextConfig({\n allowedDevOrigins: ['my-app.localhost', '**.my-app.localhost'],\n});\n", + }, + { + name: "next.config.mts / export default", + file: "next.config.mts", + text: 'import type { NextConfig } from "next";\nconst cfg = { allowedDevOrigins: ["**.my-app.localhost", "my-app.localhost"] } satisfies NextConfig;\nexport default cfg;\n', + }, + { + name: "рекурсивный шаблон **.localhost покрывает оба уровня", + file: "next.config.js", + text: "module.exports = { allowedDevOrigins: ['**.localhost'] };", + }, + { + name: "шаблон со значением из другой секции и комментарии", + file: "next.config.js", + text: "// allowedDevOrigins: ['example.com'] — закомментировано, не считается\nmodule.exports = {\n /* шум */\n rewrites: async () => [{ source: '/a', destination: 'http://b' }],\n allowedDevOrigins: ['my-app.localhost', '**.my-app.localhost'],\n};\n", + }, + ]; + + for (const c of coveredConfigs) { + it(`покрытие есть: ${c.name} → ok, без предупреждения`, async () => { + const dir = await makeProject({ next: "15.5.0", configName: c.file, configText: c.text }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("ok"); + expect(r.coversPegasOrigins).toBe(true); + expect(r.warning).toBeNull(); + expect(r.hint).toBeNull(); + expect(r.configFile).toBe(c.file); + } finally { + await cleanup(dir); + } + }); + } + + it("allowedDevOrigins: ['*.localhost'] НЕ покрывает поддомен сервиса → not-covered с пояснением", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['*.localhost'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("not-covered"); + expect(r.uncoveredDomains).toEqual(["api.my-app.localhost"]); + expect(r.warning).toContain("одному уровню поддомена"); + expect(r.warning).toContain("'**.my-app.localhost'"); + } finally { + await cleanup(dir); + } + }); + + it("['*.localhost'] при одноуровневом домене достаточно (встроенный список тоже его покрывает)", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['*.localhost'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["my-app.localhost"] }); + expect(r.status).toBe("ok"); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("посторонние значения (['example.com']) → not-covered", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['example.com', '*.example.com'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("not-covered"); + expect(r.configuredOrigins).toEqual(["example.com", "*.example.com"]); + // my-app.localhost покрыт встроенным *.localhost (Next 15.5); поддомен сервиса — нет. + expect(r.uncoveredDomains).toEqual(["api.my-app.localhost"]); + expect(r.warning).toContain("api.my-app.localhost"); + expect(r.warning).not.toContain("example.com"); + } finally { + await cleanup(dir); + } + }); + + it("пустой массив → not-covered (опция задана, но ничего не разрешает)", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = { allowedDevOrigins: [] };" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("not-covered"); + expect(r.hasAllowedDevOrigins).toBe(true); + expect(r.configuredOrigins).toEqual([]); + } finally { + await cleanup(dir); + } + }); + + it("вычисляемое значение → indeterminate: подсказка вместо ложного предупреждения", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: "const origins = require('./origins');\nmodule.exports = { allowedDevOrigins: origins };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("indeterminate"); + expect(r.warning).toBeNull(); + expect(r.hint).toContain("вычисляется динамически"); + expect(r.hint).toContain(NEXTJS_GUIDE_PATH); + } finally { + await cleanup(dir); + } + }); + + it("частично вычисляемое значение (спред) → indeterminate, прочитанные строки сохранены", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.ts", + configText: "export default { allowedDevOrigins: ['my-app.localhost', ...extraOrigins] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("indeterminate"); + expect(r.configuredOrigins).toEqual(["my-app.localhost"]); + expect(r.hint).not.toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("вычисляемый ключ объекта → indeterminate, а не обвинение в отсутствии опции", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: 'const KEY = "allowedDevOrigins";\nmodule.exports = { [KEY]: ["my-app.localhost", "**.my-app.localhost"] };', + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("indeterminate"); + expect(r.warning).toBeNull(); + expect(r.hint).toContain("не удалось разобрать"); + } finally { + await cleanup(dir); + } + }); + + it("приоритет файлов: next.config.js читается раньше next.config.ts", async () => { + const dir = await makeProject({ next: "15.5.0" }); + try { + await fs.writeFile(path.join(dir, "next.config.js"), "module.exports = { allowedDevOrigins: ['my-app.localhost', '**.my-app.localhost'] };"); + await fs.writeFile(path.join(dir, "next.config.ts"), "export default {};"); + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.configFile).toBe("next.config.js"); + expect(r.status).toBe("ok"); + } finally { + await cleanup(dir); + } + }); + + it("next.config.cjs принимается запасным вариантом", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.cjs", + configText: "module.exports = { allowedDevOrigins: ['my-app.localhost', '**.my-app.localhost'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.configFile).toBe("next.config.cjs"); + expect(r.status).toBe("ok"); + } finally { + await cleanup(dir); + } + }); +}); + +describe("nextjs-audit: область применения (dev-домены)", () => { + it("dev-домены без .localhost → аудит молчит", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = {};" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["dev.example.com", "api.example.com"] }); + expect(r.status).toBe("not-applicable"); + expect(r.warning).toBeNull(); + expect(r.hint).toBeNull(); + expect(r.checkedDomains).toEqual([]); + } finally { + await cleanup(dir); + } + }); + + it("пустой список доменов (в контракте нет development-доменов) → молчит", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = {};" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: [] }); + expect(r.status).toBe("not-applicable"); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("только localhost в доменах → молчит (Next его разрешает всегда)", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = {};" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["localhost"] }); + expect(r.status).toBe("not-applicable"); + expect(r.warning).toBeNull(); + } finally { + await cleanup(dir); + } + }); + + it("домены не переданы → аудит не блокируется, проверяет канонические формы", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = {};" }); + try { + const r = await auditNextjsDevOrigins(dir); + expect(r.status).toBe("missing-option"); + expect(r.domainsFromContract).toBe(false); + expect(r.warning).toContain(".localhost"); + expect(r.warning).toContain("'**.localhost'"); + // Без контракта реальные hostname не выдумываются. + expect(r.checkedDomains).toEqual([]); + expect(r.uncoveredDomains).toEqual([]); + } finally { + await cleanup(dir); + } + }); + + it("домены не переданы + legacy 14.2 → рецепт с плейсхолдером ", async () => { + const dir = await makeProject({ next: "14.2.33", configName: "next.config.js", configText: "module.exports = {};" }); + try { + const r = await auditNextjsDevOrigins(dir); + expect(r.status).toBe("missing-option"); + expect(r.recommendedOrigins).toEqual([".localhost", "**..localhost"]); + expect(r.warning).toContain("'**..localhost'"); + } finally { + await cleanup(dir); + } + }); + + it("неизвестные домены + частичное покрытие → not-covered с условной формулировкой", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['*.localhost'] };", + }); + try { + // Каноническая проба ..localhost не покрыта *.localhost. + const r = await auditNextjsDevOrigins(dir); + expect(r.status).toBe("not-covered"); + expect(r.warning).toContain("..localhost"); + expect(r.warning).toContain("Если проект публикует"); + // Сообщение собирается из цельных предложений: нет пунктуационных артефактов. + expect(r.warning).not.toMatch(/\.\s[—–-]/); + expect(r.warning).not.toMatch(/\.\s+[a-zа-я]/); + } finally { + await cleanup(dir); + } + }); + + it("Next.js 16.2+ блокирует непокрытый origin → предупреждение говорит про 403", async () => { + const dir = await makeProject({ + next: "16.2.0", + configName: "next.config.ts", + configText: "export default { allowedDevOrigins: ['example.com'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["api.my-app.localhost"] }); + expect(r.status).toBe("not-covered"); + expect(r.warning).toContain("403"); + expect(r.builtinOrigins).toEqual(["*.localhost", "localhost"]); + } finally { + await cleanup(dir); + } + }); + + it("14.2 с ['*.localhost'] → not-covered и пояснение про ветку 14.2", async () => { + const dir = await makeProject({ + next: "14.2.33", + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['*.localhost'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["api.my-app.localhost"] }); + expect(r.status).toBe("not-covered"); + expect(r.warning).toContain("14.2"); + expect(r.warning).toContain("'**.my-app.localhost'"); + } finally { + await cleanup(dir); + } + }); + + it("экранированные кавычки в строках читаются корректно", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: 'module.exports = { allowedDevOrigins: ["my-app.localhost", "**.my-app.localhost"], note: "a \\" b" };', + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.status).toBe("ok"); + expect(r.configuredOrigins).toEqual(["my-app.localhost", "**.my-app.localhost"]); + } finally { + await cleanup(dir); + } + }); + + it("домены со схемой и портом нормализуются", async () => { + const dir = await makeProject({ + next: "15.5.0", + configName: "next.config.js", + configText: "module.exports = { allowedDevOrigins: ['my-app.localhost', '**.my-app.localhost'] };", + }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: ["https://api.my-app.localhost:443/", " MY-APP.localhost "] }); + expect(r.status).toBe("ok"); + expect(r.checkedDomains).toEqual(["api.my-app.localhost", "my-app.localhost"]); + } finally { + await cleanup(dir); + } + }); + + it("домены из разных проектов: непокрытые перечисляются, длинные списки сокращаются", async () => { + // Пять двухуровневых доменов разных зон: встроенный *.localhost их не + // покрывает, конфиг пуст → все пять непокрыты, текст сокращается. + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = { allowedDevOrigins: ['a.localhost'] };" }); + try { + const domains = ["api.a.localhost", "api.b.localhost", "api.c.localhost", "api.d.localhost", "api.e.localhost"]; + const r = await auditNextjsDevOrigins(dir, { devDomains: domains }); + expect(r.status).toBe("not-covered"); + expect(r.uncoveredDomains).toEqual(domains); + expect(r.warning).toContain("и ещё"); + expect(r.recommendedOrigins).toEqual([ + "a.localhost", + "**.a.localhost", + "b.localhost", + "**.b.localhost", + "c.localhost", + "**.c.localhost", + "d.localhost", + "**.d.localhost", + "e.localhost", + "**.e.localhost", + ]); + } finally { + await cleanup(dir); + } + }); + + it("несуществующий каталог проекта → не падает", async () => { + const missing = path.join(os.tmpdir(), `pegas-nextjs-absent-${Date.now()}`); + const r = await auditNextjsDevOrigins(missing, { devDomains: DEV_DOMAINS }); + expect(r.nextjsDetected).toBe(false); + expect(r.warning).toBeNull(); + }); +}); + +describe("nextjs-audit: форма результата", () => { + it("все поля инициализированы (JSON-сериализация без undefined)", async () => { + const dir = await makeProject({ next: "15.5.0", configName: "next.config.js", configText: "module.exports = {};" }); + try { + const r: NextjsAuditFinding = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + const keys: Array = [ + "status", + "nextjsDetected", + "nextVersion", + "configFile", + "hasAllowedDevOrigins", + "coversPegasOrigins", + "checkedDomains", + "domainsFromContract", + "uncoveredDomains", + "configuredOrigins", + "builtinOrigins", + "recommendedOrigins", + "warning", + "hint", + ]; + const parsed = JSON.parse(JSON.stringify(r)); + for (const k of keys) expect(Object.prototype.hasOwnProperty.call(parsed, k)).toBe(true); + expect(parsed.status).toBe("missing-option"); + } finally { + await cleanup(dir); + } + }); + + it("текст предупреждения содержит ссылку на гайд и готовый сниппет", async () => { + const dir = await makeProject({ next: "15.5.0" }); + try { + const r = await auditNextjsDevOrigins(dir, { devDomains: DEV_DOMAINS }); + expect(r.warning).toContain(NEXTJS_GUIDE_PATH); + expect(r.warning).toContain("allowedDevOrigins: ['my-app.localhost', '**.my-app.localhost']"); + expect(r.warning).toContain("перезапустите dev-сервер"); + expect(r.warning).not.toMatch(/[😀-🿿]/u); // без эмодзи: их печатает check.ts + } finally { + await cleanup(dir); + } + }); + + it("гайд существует в репозитории (ссылка из предупреждения резолвится)", async () => { + const guide = path.join(process.cwd(), NEXTJS_GUIDE_PATH); + const stat = await fs.stat(guide); + expect(stat.isFile()).toBe(true); + }); +}); diff --git a/src/nextjs-audit.ts b/src/nextjs-audit.ts new file mode 100644 index 0000000..0b92184 --- /dev/null +++ b/src/nextjs-audit.ts @@ -0,0 +1,868 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { normalizeDomain } from "./contract.js"; + +/** + * Аудит `allowedDevOrigins` в конфиге Next.js (issue #28). + * + * Зачем: Pegas публикует локальные сервисы на доменах `.localhost` и + * `..localhost`. Next.js защищает dev-ресурсы (`/_next/*`, + * `/__nextjs*`) от cross-origin обращений: hostname из `Origin`/`Referer` + * сравнивается со списком разрешённых, и домен шлюза в него может не попасть. + * Итог — предупреждение или 403 на HMR-сокете, что выглядит как проблема CORS + * на стороне шлюза. + * + * Модуль рид-онли и без побочных эффектов: читает `package.json`, + * `next.config.*` и (если есть) `node_modules/next/package.json`, возвращает + * результат аудита. Вызывается из `pegas check`. + * + * Ограничения (честно: сторонних парсеров JS/TS в проекте нет и не будет — + * AGENTS.md, «ноль сторонних зависимостей»): + * - конфиг разбирается текстовой эвристикой, а не AST: плоский массив строк + * распознаётся, вычисляемые значения (переменные, спреды, вызовы функций, + * интерполяция `${...}`) — нет, в этом случае возвращается статус + * `indeterminate` и мягкая подсказка вместо ложного предупреждения; + * - просматривается только корень проекта. Монорепо с Next-приложением в + * `apps/web` аудитор не увидит: он промолчит, а не предупредит (молчание + * безопаснее ложного срабатывания); + * - Next-конфиг, собранный из нескольких файлов (`...require('./base')`), + * разбирается только в части исходного файла. + * + * Факты о Next.js сверены с исходниками тегов v14.2.30, v14.2.33, v15.3.0, + * v15.5.0, v16.0.0, v16.2.0, v16.3.0, v16.4.5: + * - `packages/next/src/server/config-schema.ts` — опция `allowedDevOrigins` + * (`z.array(z.string()).optional()`) появилась в 15.3.0 и в 14.2.30 (backport + * в ветку 14.2); в 14.2.29 и ниже, в 15.0–15.2 её нет; + * - `packages/next/src/shared/lib/constants.ts` (CONFIG_FILES) — порядок + * загрузки: `next.config.js`, `.mjs`, `.ts`, далее `.mts`; + * - `packages/next/src/server/app-render/csrf-protection.ts` — сопоставление + * hostname с шаблоном: `*` — ровно одна метка, `**` — одна и более и только + * первым сегментом; в ветке 14.2 действует дополнительное ограничение + * (`depth < 2`): две последние метки шаблона обязаны быть литералами, поэтому + * там не работают ни `*.localhost`, ни `**.localhost`; + * - `packages/next/src/server/lib/router-utils/block-cross-site.ts` (до 16.1) и + * `block-cross-site-dev.ts` (с 16.2) — встроенный список всегда содержит + * `localhost`, до 16.2 также `*.localhost`, с 16.3 — `**.localhost`; + * до 16.1 при незаданной опции Next только предупреждает, с 16.2 — блокирует + * (403) независимо от настройки. + */ + +/** Порядок поиска конфига: сначала то, что реально загружает Next (CONFIG_FILES), + * затем запасные варианты, которые проект может держать (`.cjs` Next не читает). */ +const CONFIG_FILE_ORDER = [ + "next.config.js", + "next.config.mjs", + "next.config.ts", + "next.config.mts", + "next.config.cjs", +]; + +/** Путь к публичному гайду — печатается в предупреждении. */ +export const NEXTJS_GUIDE_PATH = "docs/how-to/nextjs.md"; + +/** + * Канонические формы dev-доменов Pegas: используются, когда вызывающий код не + * передал домены (контракт недоступен) — аудит проверяет покрытие обеих форм. + */ +const GENERIC_PEGAS_DOMAINS = ["pegas-app.localhost", "pegas-svc.pegas-app.localhost"]; + +/** Итог аудита. */ +export type NextjsDevOriginsStatus = + /** Next.js в package.json не обнаружен — аудит не применяется. */ + | "not-detected" + /** Версия Next.js не знает опцию allowedDevOrigins (до 15.3.0 / до 14.2.30). */ + | "unsupported" + /** Dev-домены проекта не используют зону *.localhost — предупреждать не о чем. */ + | "not-applicable" + /** Все проверяемые dev-домены покрыты (конфигом и/или встроенным списком). */ + | "ok" + /** Next.js установлен, но файла next.config.* нет и покрытие неполное. */ + | "missing-config" + /** Конфиг есть, ключа allowedDevOrigins нет и покрытие неполное. */ + | "missing-option" + /** Ключ есть, но часть dev-доменов Pegas не покрыта. */ + | "not-covered" + /** Значение ключа вычисляется динамически либо конфиг не читается. */ + | "indeterminate"; + +export interface NextjsAuditOptions { + /** + * Dev-домены проекта из контракта (сценарий `development`). + * + * - `undefined` — домены неизвестны: аудит не блокируется и проверяет + * канонические формы `GENERIC_PEGAS_DOMAINS`; + * - массив без единого домена зоны `.localhost` — аудит не применяется: + * проект не публикует dev-сервисы на доменах шлюза Pegas, шуметь не о чем. + */ + devDomains?: string[]; +} + +export interface NextjsAuditFinding { + /** Итоговый статус аудита (машиночитаемо, для --json потребителей). */ + status: NextjsDevOriginsStatus; + /** true, если Next.js вообще обнаружен в package.json проекта. */ + nextjsDetected: boolean; + /** Версия Next.js: из node_modules, иначе объявленный диапазон; null — не определена. */ + nextVersion: string | null; + /** Найденный файл конфига относительно корня проекта (или null). */ + configFile: string | null; + /** Найден ли ключ allowedDevOrigins. */ + hasAllowedDevOrigins: boolean; + /** Покрывают ли значение и встроенный список Next dev-домены Pegas. */ + coversPegasOrigins: boolean; + /** Домены, которые проверялись (нормализованные). */ + checkedDomains: string[]; + /** true — домены взяты из контракта; false — канонические формы-плейсхолдеры. */ + domainsFromContract: boolean; + /** Домены, не покрытые ни конфигом, ни встроенным списком. */ + uncoveredDomains: string[]; + /** Строки, разобранные из массива allowedDevOrigins (что удалось прочитать). */ + configuredOrigins: string[]; + /** Встроенный в Next.js список разрешённых origin для этой версии. */ + builtinOrigins: string[]; + /** Готовый список значений, покрывающий dev-домены проекта. */ + recommendedOrigins: string[]; + /** Текст предупреждения для вывода (null — проблемы нет). */ + warning: string | null; + /** Мягкая подсказка (статус indeterminate): проверить конфиг вручную. */ + hint: string | null; +} + +/** Разобранная semver-тройка. */ +interface SemVer { + major: number; + minor: number; + patch: number; +} + +/** + * Достаёт первую semver-тройку из строки версии или диапазона + * (`^15.5.0` → 15.5.0, `14.2.33` → 14.2.33, `latest`/`*` → null). + */ +export function parseSemverLoose(raw: string | null | undefined): SemVer | null { + if (!raw) return null; + const m = /(\d+)\.(\d+)\.(\d+)/.exec(raw); + if (!m) return null; + return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) }; +} + +/** + * Поддерживает ли версия Next.js опцию `allowedDevOrigins`: 15.3.0+ и + * backport 14.2.30+ в ветке 14.2 (проверено по `config-schema.ts`). + * Неизвестная версия трактуется как поддерживающая: лучше предупредить, + * чем промолчать о реально заблокированном HMR. + */ +export function supportsAllowedDevOrigins(version: string | null): boolean { + const v = parseSemverLoose(version); + if (!v) return true; + if (v.major >= 15) return v.major > 15 || v.minor >= 3; + if (v.major === 14) return v.minor > 2 || (v.minor === 2 && v.patch >= 30); + return false; +} + +/** + * Ветка 14.2 использует более строгий сопоставитель шаблонов + * (`csrf-protection.ts` с ограничением `depth < 2`): метка-подстановка в двух + * последних сегментах hostname запрещена, поэтому там не работают ни + * `*.localhost`, ни `**.localhost` — только точные имена и шаблоны выше уровня + * `.localhost`. + */ +export function usesLegacyWildcardMatcher(version: string | null): boolean { + const v = parseSemverLoose(version); + return v !== null && v.major === 14; +} + +/** + * Встроенный в Next.js список разрешённых dev-origin для версии: + * `localhost` — всегда; `*.localhost` — до 16.2 включительно; `**.localhost` — + * с 16.3. Неизвестная версия трактуется как `*.localhost` (средний случай: + * не занижаем покрытие и не завышаем его до `**`). + */ +export function builtinDevOrigins(version: string | null): string[] { + const v = parseSemverLoose(version); + if (v && (v.major > 16 || (v.major === 16 && v.minor >= 3))) return ["**.localhost", "localhost"]; + return ["*.localhost", "localhost"]; +} + +/** + * Как Next.js отреагирует на dev-запрос с непокрытого origin: + * - 16.2+ — блокирует (HTTP 403) всегда, независимо от наличия опции; + * - 14.2.30–16.1 — блокирует, только если `allowedDevOrigins` задан; без опции + * печатает предупреждение и пропускает запрос + * (`mode = typeof allowedDevOrigins === 'undefined' ? 'warn' : 'block'`). + */ +export function blocksUnlistedDevOrigin(version: string | null, optionDefined: boolean): boolean { + const v = parseSemverLoose(version); + if (!v) return true; // версия неизвестна — считаем худшим случаем (современный Next) + if (v.major > 16 || (v.major === 16 && v.minor >= 2)) return true; + return optionDefined; +} + +/** + * Сопоставление hostname с шаблоном — семантика Next.js 15.3+: + * `*` заменяет ровно одну метку, `**` — одну и более и только первым сегментом; + * частичная подстановка внутри метки не поддерживается. + */ +export function matchWildcardDomain(domain: string, pattern: string): boolean { + const domainParts = domain.split("."); + const patternParts = pattern.split("."); + if (patternParts.length < 1) return false; + if (domainParts.length < patternParts.length) return false; + // Шаблон из одной метки-подстановки (`*`, `**`) не должен матчить весь домен. + if (patternParts.length === 1 && (patternParts[0] === "*" || patternParts[0] === "**")) return false; + + while (patternParts.length) { + const patternPart = patternParts.pop(); + const domainPart = domainParts.pop(); + switch (patternPart) { + case "": + return false; + case "*": + if (domainPart) continue; + return false; + case "**": + // `**` допустим только первым сегментом шаблона. + if (patternParts.length > 0) return false; + return domainPart !== undefined; + default: + if (domainPart !== patternPart) return false; + } + } + return domainParts.length === 0; +} + +/** + * Сопоставление hostname с шаблоном — семантика ветки Next.js 14.2: + * две последние метки шаблона обязаны быть литералами, подстановка разрешена + * только выше уровня домена. `*.localhost` и `**.localhost` здесь не работают. + */ +export function matchWildcardDomainLegacy(domain: string, pattern: string): boolean { + const domainParts = domain.split("."); + const patternParts = pattern.split("."); + if (patternParts.length < 1) return false; + if (domainParts.length < patternParts.length) return false; + + let depth = 0; + while (patternParts.length && depth++ < 2) { + const patternPart = patternParts.pop(); + const domainPart = domainParts.pop(); + switch (patternPart) { + case "": + case "*": + case "**": + return false; + default: + if (domainPart !== patternPart) return false; + } + } + + while (patternParts.length) { + const patternPart = patternParts.pop(); + const domainPart = domainParts.pop(); + switch (patternPart) { + case "": + return false; + case "*": + if (domainPart) continue; + return false; + case "**": + if (patternParts.length > 0) return false; + return domainPart !== undefined; + default: + if (domainPart !== patternPart) return false; + } + } + return domainParts.length === 0; +} + +/** + * Покрыт ли dev-домен списком `allowedDevOrigins`. `localhost` разрешён Next.js + * всегда (плюс hostname, с которым запущен dev-сервер), поэтому он здесь + * рассматривается отдельно, чтобы не зависеть от списка. + */ +export function isDevOriginCovered(domain: string, entries: string[], legacyMatcher = false): boolean { + const target = String(domain ?? "").trim().toLowerCase(); + if (target === "localhost") return true; + return entries.some((raw) => { + const entry = String(raw ?? "").trim().toLowerCase(); + if (!entry) return false; + if (entry === target) return true; + return legacyMatcher ? matchWildcardDomainLegacy(target, entry) : matchWildcardDomain(target, entry); + }); +} + +/** + * Список значений `allowedDevOrigins`, покрывающий dev-домены проекта на всех + * версиях Next.js, где опция существует: для каждого корня зоны + * (`.localhost`) — точное имя и рекурсивный шаблон. Точное имя нужно, + * потому что `**.` не соответствует самому корню зоны, а работать это + * обязано и в ветке 14.2, где `**.localhost` не сопоставляется. + */ +export function recommendedDevOrigins(devDomains?: string[]): string[] { + const roots = new Set(); + for (const raw of devDomains ?? []) { + const domain = normalizeDomain(String(raw ?? "")); + if (!domain.endsWith(".localhost")) continue; + const labels = domain.split("."); + if (labels.length < 2) continue; + roots.add(labels.slice(-2).join(".")); + } + if (roots.size === 0) return ["**.localhost"]; + const out: string[] = []; + for (const root of [...roots].sort()) { + out.push(root, `**.${root}`); + } + return out; +} + +/** Очищенный исходник плюс маска «символ находится внутри строкового литерала». */ +export interface StrippedSource { + /** Код без комментариев и regex-литералов; строки сохранены дословно. */ + code: string; + /** Маска по индексам `code`: true — внутри строкового литерала. */ + inString: boolean[]; +} + +/** + * Убирает из исходника комментарии и regex-литералы, сохраняя строковые + * литералы дословно, и помечает, какие символы попали внутрь строк: дальше по + * очищенному тексту ищется ключ и его значение, а маска защищает от ложных + * находок (слово `allowedDevOrigins` внутри строки или комментария — не ключ). + * + * Regex-литералы пропускать обязательно: в конфигах Next часто встречаются + * правила вида `/https?:\/\//`, и наивный поиск `//` принял бы их за начало + * комментария и «съел» бы остаток файла. + */ +export function stripSource(source: string): StrippedSource { + let out = ""; + const inString: boolean[] = []; + let i = 0; + const n = source.length; + // Последний значимый символ и последнее слово — чтобы отличить regex от деления. + let prevChar = ""; + let prevWord = ""; + + const push = (text: string, insideString: boolean): void => { + out += text; + for (let k = 0; k < text.length; k++) inString.push(insideString); + }; + + const regexAllowed = (): boolean => { + if (prevChar === "") return true; + if ("(,=:[!&|?{};+-*%~^<>".includes(prevChar)) return true; + return ["return", "typeof", "case", "in", "of", "new", "delete", "void", "do", "else", "yield", "await"].includes(prevWord); + }; + + while (i < n) { + const c = source[i]; + + // Строковый литерал: копируем дословно, включая экранированные символы. + if (c === '"' || c === "'" || c === "`") { + const quote = c; + let literal = quote; + i++; + while (i < n) { + const d = source[i]; + literal += d; + if (d === "\\") { + literal += source[i + 1] ?? ""; + i += 2; + continue; + } + i++; + if (d === quote) break; + } + push(literal, true); + prevChar = quote; + prevWord = ""; + continue; + } + + // Строчный комментарий. + if (c === "/" && source[i + 1] === "/") { + while (i < n && source[i] !== "\n") i++; + push(" ", false); + prevChar = " "; + prevWord = ""; + continue; + } + // Блочный комментарий. + if (c === "/" && source[i + 1] === "*") { + i += 2; + while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++; + i += 2; + push(" ", false); + prevChar = " "; + prevWord = ""; + continue; + } + // Regex-литерал: пропускаем целиком (с классами `[...]` и флагами). + if (c === "/" && regexAllowed()) { + i++; + let inClass = false; + while (i < n) { + const d = source[i]; + if (d === "\\") { + i += 2; + continue; + } + if (d === "\n") break; // незакрытый литерал — не regex, прекращаем + if (d === "[") inClass = true; + else if (d === "]") inClass = false; + else if (d === "/" && !inClass) { + i++; + break; + } + i++; + } + while (i < n && /[dgimsuvy]/.test(source[i])) i++; + push(" ", false); + prevChar = "/"; + prevWord = ""; + continue; + } + + push(c, false); + // Пробел токеном не является, но и «слово» не разрывает: `return /re/` + // обязан определяться как regex. + if (!/\s/.test(c)) { + prevChar = c; + if (/[A-Za-z0-9_$]/.test(c)) prevWord += c; + else prevWord = ""; + } + i++; + } + return { code: out, inString }; +} + +/** Очищенный от комментариев и regex-литералов исходник (без маски строк). */ +export function stripComments(source: string): string { + return stripSource(source).code; +} + +/** Результат разбора значения `allowedDevOrigins`. */ +export interface ParsedDevOrigins { + /** Ключ найден (в любом виде). */ + found: boolean; + /** Строковые литералы, которые удалось прочитать. */ + entries: string[]; + /** Значение разобрано полностью: нет вычисляемых частей. */ + complete: boolean; + /** + * Слово `allowedDevOrigins` в файле встречается, но ключ со значением не + * распознан (вычисляемый ключ вида `{ [ORIGINS_KEY]: [...] }`, нестандартная + * форма записи). Значение опции неизвестно — это неоднозначность, а не + * доказанное отсутствие. + */ + ambiguous?: boolean; +} + +/** Слова, встречающиеся в литеральных конфигах и не означающие вычислимое значение. */ +const NOISE_WORDS = new Set(["as", "const", "satisfies", "true", "false", "null", "undefined"]); + +/** + * Ищет ключ `allowedDevOrigins` и разбирает его значение. + * + * Поддерживаемые формы записи: `module.exports = { allowedDevOrigins: [...] }`, + * `export default { allowedDevOrigins: [...] }`, `defineNextConfig({ ... })`, + * `const nextConfig = { allowedDevOrigins: [...] }`, `"allowedDevOrigins": [...]`, + * `nextConfig.allowedDevOrigins = [...]`. + * + * Ключом не считаются: `allowedDevOrigins?: string[]` (аннотация типа TS), + * `other.allowedDevOrigins: [...]` (чтение чужого свойства без присваивания), + * упоминание слова в комментарии или внутри произвольной строки + * (`"см. allowedDevOrigins: ..."` — не ключ). + */ +export function parseAllowedDevOrigins(source: string): ParsedDevOrigins { + const { code, inString } = stripSource(source); + const keyRe = /allowedDevOrigins/g; + let match: RegExpExecArray | null; + let ambiguous = false; + + while ((match = keyRe.exec(code)) !== null) { + const start = match.index; + const end = start + match[0].length; + + if (inString[start]) { + // Строковый литерал, целиком равный слову, — это либо ключ объекта в + // кавычках (`"allowedDevOrigins": [...]`), либо значение, например имя + // вычисляемого ключа (`const K = "allowedDevOrigins"; { [K]: [...] }`). + const open = code[start - 1]; + const close = code[end]; + if ((open !== '"' && open !== "'") || close !== open) continue; // слово внутри текста + const afterQuote = code.slice(end + 1); + if (/^\s*[:=]/.test(afterQuote)) return parseValueExpression(afterQuote.replace(/^\s*[:=]\s*/, "")); + ambiguous = true; // строка — значение, опция задаётся через неё + continue; + } + + const prevNonSpace = code.slice(Math.max(0, start - 24), start).replace(/\s+$/, ""); + const prevChar = prevNonSpace.slice(-1); + if (prevChar === ".") { + // Допустима только форма присваивания: `cfg.allowedDevOrigins = [...]`. + if (!/^\s*=([^=])/.test(code.slice(end))) continue; // чтение чужого свойства + } else if (/[A-Za-z0-9_$]/.test(prevChar)) { + continue; // часть другого идентификатора + } + + const after = code.slice(end); + const valueStart = /^\s*(\??)\s*([:=])/.exec(after); + if (!valueStart) continue; + if (valueStart[1] === "?") continue; // аннотация типа: `allowedDevOrigins?: string[]` + + return parseValueExpression(after.slice(valueStart[0].length).replace(/^\s+/, "")); + } + + // Ключ со значением не распознан, хотя опция упоминается как значение + // (вычисляемый ключ): утверждать «опции нет» нельзя. + return { found: false, entries: [], complete: false, ambiguous }; +} + +/** Разбор выражения значения: массив строк, одиночная строка либо «не разобрано». */ +function parseValueExpression(text: string): ParsedDevOrigins { + const first = text[0] ?? ""; + + // Одиночная строка: схема Next требует массив, но проект мог написать строку. + if (first === '"' || first === "'" || first === "`") { + const literal = readStringLiteral(text, 0); + if (literal && !literal.interpolated) return { found: true, entries: [literal.value], complete: true }; + return { found: true, entries: [], complete: false }; + } + + if (first !== "[") { + // Переменная, вызов функции, spread, тернарник — достоверно не разобрать. + return { found: true, entries: [], complete: false }; + } + + const entries: string[] = []; + let complete = true; + let depth = 0; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '"' || c === "'" || c === "`") { + const literal = readStringLiteral(text, i); + if (!literal) { + complete = false; + break; + } + if (literal.interpolated) complete = false; + else if (depth === 1) entries.push(literal.value); + i = literal.end; + continue; + } + if (c === "[" || c === "(" || c === "{") { + depth++; + if (depth > 1) complete = false; // вложенные структуры — не плоский список строк + continue; + } + if (c === "]" || c === ")" || c === "}") { + depth--; + if (c === "]" && depth === 0) break; + continue; + } + if (c === "." && text[i + 1] === "." && text[i + 2] === ".") { + complete = false; // spread: часть значений приходит извне + i += 2; + continue; + } + if (/[A-Za-z0-9_$]/.test(c)) { + const word = /^[A-Za-z0-9_$]+/.exec(text.slice(i))?.[0] ?? ""; + if (word && !NOISE_WORDS.has(word)) complete = false; // идентификатор/вызов + i += Math.max(0, word.length - 1); + continue; + } + } + return { found: true, entries, complete }; +} + +/** Прочитанный строковый литерал. */ +interface StringLiteral { + value: string; + /** Индекс последнего символа литерала (включая закрывающую кавычку). */ + end: number; + /** Есть ли интерполяция `${...}` — такое значение недостоверно. */ + interpolated: boolean; +} + +function readStringLiteral(text: string, start: number): StringLiteral | null { + const quote = text[start]; + if (quote !== '"' && quote !== "'" && quote !== "`") return null; + let value = ""; + let interpolated = false; + let i = start + 1; + for (; i < text.length; i++) { + const c = text[i]; + if (c === "\\") { + value += text[i + 1] ?? ""; + i++; + continue; + } + if (quote === "`" && c === "$" && text[i + 1] === "{") { + interpolated = true; + let nested = 1; + i += 2; + while (i < text.length && nested > 0) { + if (text[i] === "{") nested++; + else if (text[i] === "}") nested--; + i++; + } + i--; + continue; + } + if (c === quote) return { value, end: i, interpolated }; + value += c; + } + return null; // незакрытая строка +} + +/** + * Формулировка последствия для текста предупреждения: Next.js либо блокирует + * запрос (403), либо пока только печатает предупреждение — поведение зависит от + * версии и от того, задана ли опция вовсе. + */ +function effectPhrase(version: string | null, optionDefined: boolean): string { + return blocksUnlistedDevOrigin(version, optionDefined) + ? "dev-ресурсы Next.js (HMR, /_next/*) возвращают 403" + : "dev-ресурсы Next.js (HMR, /_next/*) помечаются как cross-origin: Next.js печатает предупреждение, " + + "а в следующих мажорных версиях блокирует их"; +} + +/** Короткий список доменов для текста предупреждения: длинный нечитаем. */ +function formatDomains(domains: string[], max = 4): string { + if (domains.length === 0) return ""; + if (domains.length <= max) return domains.join(", "); + return `${domains.slice(0, max).join(", ")} и ещё ${domains.length - max}`; +} + +/** Литерал списка значений для вставки в текст: `['a', '**.a']`. */ +function formatEntries(entries: string[]): string { + return `[${entries.map((e) => `'${e}'`).join(", ")}]`; +} + +function emptyFinding(): NextjsAuditFinding { + return { + status: "not-detected", + nextjsDetected: false, + nextVersion: null, + configFile: null, + hasAllowedDevOrigins: false, + coversPegasOrigins: false, + checkedDomains: [], + domainsFromContract: false, + uncoveredDomains: [], + configuredOrigins: [], + builtinOrigins: [], + recommendedOrigins: [], + warning: null, + hint: null, + }; +} + +/** Читает JSON, не бросая исключение: битый файл — это null, а не падение аудита. */ +async function readJsonSafe(file: string): Promise { + try { + return JSON.parse(await fs.readFile(file, "utf-8")); + } catch { + return null; + } +} + +/** + * Аудит `allowedDevOrigins` для проекта. Исключений не бросает: любая ошибка + * чтения трактуется как «данных нет», и аудит либо молчит, либо даёт мягкую + * подсказку — но не падает и не мешает `pegas check`. + */ +export async function auditNextjsDevOrigins( + projectRoot: string, + options: NextjsAuditOptions = {} +): Promise { + const finding = emptyFinding(); + let root: string; + try { + root = path.resolve(projectRoot); + } catch { + return finding; + } + + // 1. Next.js объявлен в package.json (dependencies или devDependencies)? + const pkg = await readJsonSafe(path.join(root, "package.json")); + const deps = pkg?.dependencies && typeof pkg.dependencies === "object" ? pkg.dependencies : null; + const devDeps = pkg?.devDependencies && typeof pkg.devDependencies === "object" ? pkg.devDependencies : null; + const declared: string | null = + (deps && typeof deps.next === "string" && deps.next) || + (devDeps && typeof devDeps.next === "string" && devDeps.next) || + null; + if (!declared) return finding; + + // 2. Установленная версия важнее объявленного диапазона: `^14.2.0` может + // развернуться и в 14.2.20 (опции нет), и в 14.2.33 (опция есть). + const installed = await readJsonSafe(path.join(root, "node_modules", "next", "package.json")); + finding.nextjsDetected = true; + finding.nextVersion = + (installed && typeof installed.version === "string" && installed.version) || declared; + + // 3. Область аудита: dev-домены проекта в зоне .localhost. + const domainsProvided = Array.isArray(options.devDomains); + const normalized = (options.devDomains ?? []).map((d) => normalizeDomain(String(d ?? ""))).filter(Boolean); + const localhostDomains = normalized.filter((d) => d === "localhost" || d.endsWith(".localhost")); + if (domainsProvided && localhostDomains.length === 0) { + finding.status = "not-applicable"; + return finding; + } + finding.domainsFromContract = domainsProvided; + // `localhost` разрешён Next.js всегда — проверять его смысла нет. + // Если контракт доменов не дал, покрытие проверяется на канонических формах + // Pegas: это внутренние пробы, в результат они не попадают (выдавать их за + // реальные домены проекта было бы неправдой). + const probeDomains = (domainsProvided ? localhostDomains : GENERIC_PEGAS_DOMAINS).filter((d) => d !== "localhost"); + if (probeDomains.length === 0) { + finding.status = "not-applicable"; + return finding; + } + finding.checkedDomains = domainsProvided ? probeDomains : []; + finding.recommendedOrigins = recommendedDevOrigins(domainsProvided ? localhostDomains : undefined); + + // 4. Версия Next.js знает опцию? До 15.3.0/14.2.30 её нет: советовать + // бессмысленно, а её наличие в конфиге Next отметит как неизвестную опцию. + if (!supportsAllowedDevOrigins(finding.nextVersion)) { + finding.status = "unsupported"; + return finding; + } + + // 5. Файл конфига: порядок загрузки Next.js, только корень проекта. + // Существование и чтение разделены: файл, который не читается (права, EISDIR), + // обязан давать мягкую подсказку, а не утверждение «опции нет». + let configText: string | null = null; + for (const name of CONFIG_FILE_ORDER) { + const file = path.join(root, name); + let stat; + try { + stat = await fs.stat(file); + } catch { + continue; // файла нет — пробуем следующий + } + if (!stat.isFile()) continue; + finding.configFile = name; + try { + configText = await fs.readFile(file, "utf-8"); + } catch { + configText = null; + } + break; + } + + const legacy = usesLegacyWildcardMatcher(finding.nextVersion); + finding.builtinOrigins = builtinDevOrigins(finding.nextVersion); + // Без контракта имя проекта неизвестно: рецепт даётся плейсхолдерами, + // инструмент не выдумывает реальные hostname. + if (!domainsProvided) { + finding.recommendedOrigins = legacy + ? [".localhost", "**..localhost"] + : ["**.localhost"]; + } + const recipe = formatEntries(finding.recommendedOrigins); + // Середина сообщения — цельное предложение «где и что происходит». С + // конкретными доменами из контракта это утверждение; без контракта реальные + // имена неизвестны, поэтому факт публикации dev-сервисов на доменах шлюза + // подаётся как условие — чтобы не обвинять проект, у которого таких доменов + // может вовсе не быть. + const consequence = (domains: string[], optionDefined: boolean): string => { + const effect = effectPhrase(finding.nextVersion, optionDefined); + return domainsProvided + ? `На доменах шлюза Pegas (${formatDomains(domains)}) ${effect}.` + : `Если проект публикует dev-сервисы на доменах шлюза Pegas вида ` + + `..localhost, ${effect}.`; + }; + + // Встроенный список Next.js — часть эффективного покрытия: если он уже + // разрешает домены проекта, предупреждение было бы ложным. + if (probeDomains.every((d) => isDevOriginCovered(d, finding.builtinOrigins, legacy))) { + finding.status = "ok"; + finding.coversPegasOrigins = true; + return finding; + } + + if (finding.configFile === null) { + finding.status = "missing-config"; + finding.uncoveredDomains = domainsProvided + ? probeDomains.filter((d) => !isDevOriginCovered(d, finding.builtinOrigins, legacy)) + : []; + finding.warning = + `Next.js: next.config.* не найден. ${consequence(finding.uncoveredDomains, false)} ` + + `Создайте next.config.ts с allowedDevOrigins: ${recipe} и перезапустите dev-сервер (${NEXTJS_GUIDE_PATH}).`; + return finding; + } + + if (configText === null) { + // Файл существует, но прочитать его не удалось: не утверждаем, что опции нет. + finding.status = "indeterminate"; + const names = domainsProvided ? ` (${formatDomains(finding.checkedDomains)})` : ""; + finding.hint = + `Next.js: не удалось прочитать ${finding.configFile} — проверьте вручную, что allowedDevOrigins покрывает ` + + `домены шлюза Pegas${names} (${NEXTJS_GUIDE_PATH}).`; + return finding; + } + + // 6. Ключ и его значение. + const parsed = parseAllowedDevOrigins(configText); + finding.hasAllowedDevOrigins = parsed.found; + finding.configuredOrigins = parsed.entries; + + const effective = [...finding.builtinOrigins, ...parsed.entries]; + const uncovered = probeDomains.filter((d) => !isDevOriginCovered(d, effective, legacy)); + if (uncovered.length === 0) { + finding.status = "ok"; + finding.coversPegasOrigins = true; + return finding; + } + finding.uncoveredDomains = domainsProvided ? uncovered : []; + const uncoveredPhrase = domainsProvided ? ` (${formatDomains(uncovered)})` : ""; + + if (!parsed.found) { + // Опция упоминается, но разобрать её не удалось (например, вычисляемый + // ключ объекта): неоднозначность вместо обвинения. + if (parsed.ambiguous) { + finding.status = "indeterminate"; + finding.hint = + `Next.js: не удалось разобрать allowedDevOrigins в ${finding.configFile} — проверьте вручную, что значение ` + + `покрывает домены шлюза Pegas${uncoveredPhrase} (${NEXTJS_GUIDE_PATH}).`; + return finding; + } + finding.status = "missing-option"; + finding.warning = + `Next.js: в ${finding.configFile} отсутствует allowedDevOrigins. ${consequence(uncovered, false)} ` + + `Добавьте allowedDevOrigins: ${recipe} и перезапустите dev-сервер (${NEXTJS_GUIDE_PATH}).`; + return finding; + } + + // Значение вычисляемое: часть списка могла прийти из переменной или спреда — + // ложное предупреждение хуже подсказки проверить конфиг руками. + if (!parsed.complete) { + finding.status = "indeterminate"; + finding.hint = + `Next.js: значение allowedDevOrigins в ${finding.configFile} вычисляется динамически — pegas check не может ` + + `проверить покрытие доменов шлюза${uncoveredPhrase}. Убедитесь, что список содержит их${ + domainsProvided ? "" : " (или шаблоны зоны .localhost)" + } (${NEXTJS_GUIDE_PATH}).`; + return finding; + } + + finding.status = "not-covered"; + // Частая ловушка: `*.localhost` соответствует ровно одному уровню поддомена и + // не покрывает `..localhost`, а в ветке 14.2 не работает вовсе. + const hasSingleStar = parsed.entries.some((e) => e.trim().toLowerCase() === "*.localhost"); + const trap = hasSingleStar + ? legacy + ? " В Next.js 14.2 шаблоны `*.localhost` и `**.localhost` не сопоставляются — нужны точное имя зоны и `**..localhost`." + : " Шаблон `*.localhost` соответствует одному уровню поддомена и не покрывает `..localhost`." + : ""; + // Опция задана — значит Next.js работает в режиме block на всех версиях, + // где опция существует: непокрытый origin получает 403. + finding.warning = + `Next.js: allowedDevOrigins в ${finding.configFile} не покрывает часть dev-доменов. ` + + `${consequence(uncovered, true)}${trap} Добавьте allowedDevOrigins: ${recipe} ` + + `и перезапустите dev-сервер (${NEXTJS_GUIDE_PATH}).`; + return finding; +} + diff --git a/src/route-merge.ts b/src/route-merge.ts index 75560df..fc1118a 100644 --- a/src/route-merge.ts +++ b/src/route-merge.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { parseCaddyTopLevelBlocks } from "./commands/status.js"; +import { parseRouteMeta, serializeRouteMeta, type RouteMeta } from "./route-meta.js"; /** * issue #24: merge маршрутов по доменам в общем файле проекта. @@ -24,10 +25,16 @@ export interface RouteBlock { body: string; /** Полный исходный текст блока, включая скобки. */ raw: string; + /** Комментарий метаданных `# pegas:...`, прикреплённый к блоку (null — его нет). */ + metaComment: string | null; + /** Текст блока вместе с комментарием метаданных — то, что переносится дословно. */ + rawWithMeta: string; /** Домены из заголовка блока. */ domains: string[]; /** Первый upstream `reverse_proxy` из тела. */ upstream: string | null; + /** Разобранные метаданные маршрута (issue #30); null — строки метаданных нет. */ + meta: RouteMeta | null; } /** Разбивает содержимое route-файла на топ-левел блоки с исходным текстом. */ @@ -44,12 +51,21 @@ export function splitRouteBlocks(content: string): RouteBlock[] { .map((d) => d.trim()) .filter((d) => Boolean(d) && !d.startsWith("#")); const proxyMatch = bodyReverseProxy(block.body); + const raw = `${block.header} {${block.body}}`; + const meta = parseRouteMeta(block.leadingComments); + // К блоку переносится только его собственная строка метаданных. Прочие + // ведущие комментарии (шапка файла, рукописные пометки) принадлежат файлу: + // mergeRouteFile пишет шапку заново, и их перенос дал бы дубликаты. + const metaComment = meta ? serializeRouteMeta(meta) : null; blocks.push({ header: block.header, body: block.body, - raw: `${block.header} {${block.body}}`, + raw, + metaComment, + rawWithMeta: metaComment ? `${metaComment}\n${raw}` : raw, domains, upstream: proxyMatch, + meta, }); } return blocks; @@ -134,7 +150,9 @@ export function mergeRouteFile( if (own.length > 0) parts.push(own.join("\n\n")); for (const block of otherBlocks) { if (parts.length > 0 && parts[parts.length - 1] !== "") parts.push(""); - parts.push(block.raw); + // rawWithMeta: метаданные маршрута (`# pegas:...`) принадлежат блоку и + // обязаны переживать merge — иначе status теряет сервис/сценарий/драйвер. + parts.push(block.rawWithMeta); } let content = parts.join("\n"); if (!content.endsWith("\n")) content += "\n"; @@ -178,6 +196,6 @@ export function stripOwnerBlocks( kept.push(block); } if (kept.length === 0) return { content: null, removedDomains }; - const content = kept.map((b) => b.raw).join("\n\n") + "\n"; + const content = kept.map((b) => b.rawWithMeta).join("\n\n") + "\n"; return { content, removedDomains }; } diff --git a/src/route-meta.test.ts b/src/route-meta.test.ts new file mode 100644 index 0000000..ba0854b --- /dev/null +++ b/src/route-meta.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "bun:test"; +import { + serializeRouteMeta, + parseRouteMeta, + inferRouteMeta, + ROUTE_META_PREFIX, + type RouteMeta, +} from "./route-meta.js"; + +describe("route-meta: сериализация (issue #30)", () => { + const meta: RouteMeta = { + project: "815records", + service: "booking-api", + env: "development", + driver: "bun", + owner: "connect", + port: 3002, + }; + + it("строит однострочный комментарий с маркером # pegas:", () => { + const line = serializeRouteMeta(meta)!; + expect(line.startsWith(ROUTE_META_PREFIX)).toBe(true); + expect(line.startsWith("# pegas:")).toBe(true); + expect(line).toBe( + "# pegas:project=815records | service=booking-api | env=development | driver=bun | owner=connect | port=3002" + ); + }); + + it("не содержит переводов строк: комментарий обязан остаться одной строкой", () => { + expect(serializeRouteMeta(meta)).not.toContain("\n"); + }); + + it("порядок ключей стабилен — diff route-файлов не шумит", () => { + const reordered: RouteMeta = { port: 3002, owner: "connect", driver: "bun", env: "development", service: "booking-api", project: "815records" }; + expect(serializeRouteMeta(reordered)).toBe(serializeRouteMeta(meta)); + }); + + it("необязательные ключи пропускаются, обязательные всегда на месте", () => { + const line = serializeRouteMeta({ project: "p", service: "s", env: "production", driver: "docker" })!; + expect(line).toBe("# pegas:project=p | service=s | env=production | driver=docker"); + expect(line).not.toContain("owner"); + expect(line).not.toContain("port"); + }); + + it("неполные метаданные не сериализуются (null, а не мусорная строка)", () => { + expect(serializeRouteMeta({ project: "", service: "s", env: "dev", driver: "docker" } as RouteMeta)).toBeNull(); + expect(serializeRouteMeta({ project: "p", service: "s", env: "", driver: "docker" } as RouteMeta)).toBeNull(); + }); + + it("значение вне алфавита имён в обязательном поле → null: строка не помогла бы сопоставить сервис", () => { + expect(serializeRouteMeta({ ...meta, service: "bad service|name" } as RouteMeta)).toBeNull(); + expect(serializeRouteMeta({ ...meta, project: "proj|ect" } as RouteMeta)).toBeNull(); + }); + + it("значение вне алфавита в необязательном поле отбрасывается, обязательные сохраняются", () => { + const line = serializeRouteMeta({ project: "p", service: "s", env: "dev", driver: "docker", owner: "bad|owner" as never })!; + expect(line).toBe("# pegas:project=p | service=s | env=dev | driver=docker"); + expect(line).not.toContain("owner"); + }); +}); + +describe("route-meta: разбор (issue #30)", () => { + it("round-trip: сериализация → разбор без потерь", () => { + const meta: RouteMeta = { project: "myapp", service: "api", env: "production", driver: "docker", owner: "deploy", port: 8000 }; + expect(parseRouteMeta(serializeRouteMeta(meta))).toEqual(meta); + }); + + it("round-trip без необязательных полей", () => { + const meta: RouteMeta = { project: "myapp", service: "web", env: "development", driver: "bun" }; + expect(parseRouteMeta(serializeRouteMeta(meta))).toEqual(meta); + }); + + it("принимает массив комментариев блока и берёт строку метаданных", () => { + const meta: RouteMeta = { project: "myapp", service: "web", env: "development", driver: "bun" }; + const parsed = parseRouteMeta(["# Auto-generated by Pegas for project: myapp [development]", serializeRouteMeta(meta)!]); + expect(parsed).toEqual(meta); + }); + + it("неизвестные ключи игнорируются: рукописные правки не ломают разбор", () => { + const parsed = parseRouteMeta("# pegas:project=p | service=s | env=dev | driver=docker | future-key=x"); + expect(parsed).toEqual({ project: "p", service: "s", env: "dev", driver: "docker" }); + }); + + it("отсутствие маркера / пустой ввод → null", () => { + expect(parseRouteMeta("# обычный комментарий")).toBeNull(); + expect(parseRouteMeta([])).toBeNull(); + expect(parseRouteMeta(null)).toBeNull(); + expect(parseRouteMeta(undefined)).toBeNull(); + }); + + it("строка без обязательных полей → null (не частичные метаданные)", () => { + expect(parseRouteMeta("# pegas:service=s | env=dev")).toBeNull(); + expect(parseRouteMeta("# pegas:")).toBeNull(); + }); + + it("нечитаемый driver трактуется как docker — значение по умолчанию контракта", () => { + expect(parseRouteMeta("# pegas:project=p | service=s | env=dev | driver=weird")?.driver).toBe("docker"); + expect(parseRouteMeta("# pegas:project=p | service=s | env=dev | driver=bun")?.driver).toBe("bun"); + }); + + it("нечисловой или неположительный port не попадает в результат", () => { + expect(parseRouteMeta("# pegas:project=p | service=s | env=dev | driver=bun | port=abc")).toEqual({ + project: "p", service: "s", env: "dev", driver: "bun", + }); + expect(parseRouteMeta("# pegas:project=p | service=s | env=dev | driver=bun | port=0")?.port).toBeUndefined(); + }); + + it("регистр ключей не важен", () => { + expect(parseRouteMeta("# pegas:Project=p | SERVICE=s | Env=dev | Driver=bun")).toEqual({ + project: "p", service: "s", env: "dev", driver: "bun", + }); + }); +}); + +describe("route-meta: вывод из upstream (обратная совместимость)", () => { + it("host.docker.internal → driver bun, owner connect (правило #23)", () => { + const inferred = inferRouteMeta({ projectName: "myapp", upstream: "host.docker.internal:3000", env: "development", service: "web" }); + expect(inferred).toEqual({ + project: "myapp", service: "web", env: "development", driver: "bun", owner: "connect", port: 3000, + }); + }); + + it("scoped-алиас → driver docker, owner deploy", () => { + const inferred = inferRouteMeta({ projectName: "myapp", upstream: "abc123-production-api:8000", env: "production", service: "api" }); + expect(inferred?.driver).toBe("docker"); + expect(inferred?.owner).toBe("deploy"); + expect(inferred?.port).toBe(8000); + }); + + it("localhost и 127.0.0.1 трактуются как host-маршрут", () => { + expect(inferRouteMeta({ projectName: "p", upstream: "127.0.0.1:3000" })?.driver).toBe("bun"); + expect(inferRouteMeta({ projectName: "p", upstream: "localhost:3000" })?.driver).toBe("bun"); + }); + + it("без имени проекта или upstream метаданные не выводятся", () => { + expect(inferRouteMeta({ projectName: "", upstream: "web:3000" })).toBeNull(); + expect(inferRouteMeta({ projectName: "p", upstream: "" })).toBeNull(); + }); +}); diff --git a/src/route-meta.ts b/src/route-meta.ts new file mode 100644 index 0000000..b0adbb0 --- /dev/null +++ b/src/route-meta.ts @@ -0,0 +1,144 @@ +/** + * Метаданные маршрута в route-файле шлюза (issue #30). + * + * `pegas status` должен показывать имя сервиса, сценарий и драйвер для каждого + * маршрута. До появления метаданных это восстанавливалось эвристикой по имени + * файла и upstream, что давало неверные результаты: веб-сервисы одного проекта + * не различались, а инфраструктурные сервисы получали выдуманные эндпоинты. + * + * Формат — однострочный комментарий непосредственно перед блоком домена: + * + * # pegas:project=815records | service=booking-api | env=development | driver=bun + * api.815records.localhost { + * reverse_proxy host.docker.internal:3002 + * } + * + * Комментарий — данные для Pegas, Caddy его игнорирует. Значения ограничены + * алфавитом имён (`[a-z0-9._-]`), поэтому разделители ` | ` и `=` однозначны, + * а экранирование не требуется. + */ + +/** Маркер строки метаданных. */ +export const ROUTE_META_PREFIX = "# pegas:"; + +/** Владельцы маршрута (правило «владелец маршрута = владелец процесса», issue #23). */ +export type RouteMetaOwner = "connect" | "deploy"; + +/** Драйвер сервиса в сценарии маршрута. */ +export type RouteMetaDriver = "docker" | "bun"; + +export interface RouteMeta { + /** Имя проекта из контракта (`projectName`). */ + project: string; + /** Имя сервиса из контракта. */ + service: string; + /** Сценарий маршрута. */ + env: string; + /** Драйвер сервиса в этом сценарии. */ + driver: RouteMetaDriver; + /** Владелец блока маршрута. */ + owner?: RouteMetaOwner; + /** Логический порт сервиса (если объявлен в контракте). */ + port?: number; +} + +/** Ключи, порядок которых фиксирует сериализация (стабильный diff route-файлов). */ +const META_KEYS = ["project", "service", "env", "driver", "owner", "port"] as const; + +/** Допустимый алфавит значения метаданных: имена проекта/сервиса/сценария и драйвер. */ +const META_VALUE_RE = /^[a-z0-9._-]+$/i; + +/** + * Сериализует метаданные в однострочный комментарий. + * Возвращает null, если обязательные поля отсутствуют или содержат значение вне + * алфавита имён: маршруту лучше остаться без строки метаданных, чем получить + * комментарий, по которому сервис нельзя однозначно определить. + */ +export function serializeRouteMeta(meta: RouteMeta): string | null { + const required: Array = ["project", "service", "env", "driver"]; + for (const key of required) { + const value = meta[key]; + if (value === undefined || value === null || value === "") return null; + if (!META_VALUE_RE.test(String(value))) return null; + } + + const parts: string[] = []; + for (const key of META_KEYS) { + const value = meta[key]; + if (value === undefined || value === null || value === "") continue; + const text = String(value); + // Необязательный ключ со значением вне алфавита имён пропускается: он не + // влияет на сопоставление маршрута, а строку сделал бы неоднозначной. + if (!META_VALUE_RE.test(text)) continue; + parts.push(`${key}=${text}`); + } + if (parts.length === 0) return null; + return `${ROUTE_META_PREFIX}${parts.join(" | ")}`; +} + +/** + * Разбирает строку комментариев в метаданные. + * Принимает как одну строку, так и массив комментариев блока (берётся последняя + * строка с маркером `# pegas:`). Неизвестные ключи игнорируются: старые файлы + * и файлы, дописанные вручную, не должны ломать разбор. + */ +export function parseRouteMeta(comments: string | string[] | null | undefined): RouteMeta | null { + const lines = Array.isArray(comments) ? comments : comments ? [comments] : []; + let line: string | undefined; + for (const candidate of lines) { + const trimmed = candidate.trim(); + if (trimmed.startsWith(ROUTE_META_PREFIX)) line = trimmed; + } + if (!line) return null; + + const payload = line.slice(ROUTE_META_PREFIX.length); + const meta: Record = {}; + for (const chunk of payload.split("|")) { + const eq = chunk.indexOf("="); + if (eq <= 0) continue; + const key = chunk.slice(0, eq).trim().toLowerCase(); + const value = chunk.slice(eq + 1).trim(); + if (key && value) meta[key] = value; + } + + if (!meta.project || !meta.service || !meta.env) return null; + const driver: RouteMetaDriver = meta.driver === "bun" ? "bun" : "docker"; + const owner: RouteMetaOwner | undefined = + meta.owner === "connect" || meta.owner === "deploy" ? meta.owner : undefined; + const portNum = meta.port ? Number(meta.port) : NaN; + + return { + project: meta.project, + service: meta.service, + env: meta.env, + driver, + ...(owner ? { owner } : {}), + ...(Number.isFinite(portNum) && portNum > 0 ? { port: portNum } : {}), + }; +} + +/** + * Метаданные из upstream-а и сценария — для маршрутов, записанных до появления + * строки метаданных (обратная совместимость). Драйвер выводится из upstream: + * `host.docker.internal:` принадлежит host-процессу (issue #23). + */ +export function inferRouteMeta(input: { + projectName: string; + upstream: string | null; + env?: string; + service?: string; +}): RouteMeta | null { + if (!input.projectName || !input.upstream) return null; + const host = input.upstream.split(":")[0]; + const portRaw = input.upstream.split(":")[1]; + const portNum = portRaw ? Number(portRaw) : NaN; + const isHost = host === "host.docker.internal" || host === "127.0.0.1" || host === "localhost"; + return { + project: input.projectName, + service: input.service ?? "", + env: input.env ?? "", + driver: isHost ? "bun" : "docker", + owner: isHost ? "connect" : "deploy", + ...(Number.isFinite(portNum) && portNum > 0 ? { port: portNum } : {}), + }; +} diff --git a/src/runtime-compose.ts b/src/runtime-compose.ts index 2e6dd37..79e8149 100644 --- a/src/runtime-compose.ts +++ b/src/runtime-compose.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { PegasContract, ServiceContract, ScenarioMap, ServiceDriver } from "./types.js"; import { validatePegasContract, getEnvironmentDomains, getProjectRuntimeId } from "./contract.js"; import { composeProjectName, containerName, runtimeVolumeName } from "./naming.js"; +import { classifySharedImage, sharedPoolName } from "./shared-infra.js"; /** * Генерация эфемерного compose-манифеста из v3-контракта (docs/internal/rfc/RFC_CONTRACT_V3.md, столп 3): @@ -107,6 +108,15 @@ export function resolveInternalUrl( } const image = svc.image?.[scenario]; const scheme = schemeForImage(image); + // issue #29: shared-сервис живёт в общем пуле вне compose-проекта: + // адрес — сетевой алиас пула, а не алиас сервиса проекта. + if (svc.scope?.[scenario] === "shared") { + const profile = classifySharedImage(image); + if (!profile) { + throw new Error(errorPath + ": pegas://" + serviceName + ' — сервис в сценарии "' + scenario + '" объявлен shared, но его образ не является поддерживаемой разделяемой инфраструктурой.'); + } + return scheme + "://" + sharedPoolName(profile, scenario) + ":" + port; + } return scheme + "://" + projectId + "-" + scenario + "-" + serviceName + ":" + port; } @@ -155,7 +165,12 @@ export function buildSdkContext( const port = svc.port?.[scenario]; const image = svc.image?.[scenario]; const scheme = schemeForImage(image); - const alias = driver === "docker" ? projectId + "-" + scenario + "-" + name : undefined; + const sharedProfile = svc.scope?.[scenario] === "shared" ? classifySharedImage(image) : null; + const alias = sharedProfile + ? sharedPoolName(sharedProfile, scenario) + : driver === "docker" + ? projectId + "-" + scenario + "-" + name + : undefined; const domains = svc.domains?.[scenario]; const entry: SdkServiceContext = { driver, scheme }; if (typeof port === "number") entry.port = port; @@ -204,6 +219,9 @@ export function createRuntimeCompose( for (const [serviceName, service] of Object.entries(contract.services)) { // Фильтр по драйверу: host-сервисы pegas не поднимает (столп 3) if (at(service.runtime, scenario)?.driver !== "docker") continue; + // issue #29: shared-сервисы живут в общем пуле вне compose-проекта — + // их поднимает deploy до compose up, в манифест они не попадают. + if (at(service.scope, scenario) === "shared") continue; const image = at(service.image, scenario); const build = at(service.build, scenario); diff --git a/src/shared-infra.test.ts b/src/shared-infra.test.ts new file mode 100644 index 0000000..5a72e71 --- /dev/null +++ b/src/shared-infra.test.ts @@ -0,0 +1,561 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + classifySharedImage, + sharedPoolKey, + sharedPoolName, + sharedVolumeName, + sharedPoolPort, + loadSharedState, + saveSharedState, + registerTenant, + releaseTenant, + releaseProjectTenants, + countTenants, + ensureSharedPool, + ensureSuperuserSecret, + buildPostgresProvisionSql, + quoteSqlIdentifier, + quoteSqlLiteral, + provisionSharedTenant, + stopSharedPoolIfUnused, + releaseProjectSharedPools, + SHARED_POOL_LABEL, + SHARED_SCOPE_LABEL, + type SharedInfraDeps, + type SharedPoolProfile, + type TenantRef, +} from "./shared-infra.js"; + +/** + * Issue #29: разделяемые инфраструктурные пулы. + * Все тесты работают на фейковом исполнителе команд: логика пулов не должна + * требовать реального Docker, чтобы быть покрытой. + */ + +type Call = { command: string; args: string[]; stdin?: string }; + +/** Фейковый Docker: записывает вызовы, отвечает по правилам сценария. */ +function fakeDocker( + handlers: Partial<{ + inspect: (args: string[]) => { success: boolean; stdout: string; stderr: string }; + run: (args: string[]) => { success: boolean; stdout: string; stderr: string }; + start: (args: string[]) => { success: boolean; stdout: string; stderr: string }; + exec: (args: string[], stdin: string | undefined) => { success: boolean; stdout: string; stderr: string }; + stop: (args: string[]) => { success: boolean; stdout: string; stderr: string }; + }> = {} +): { deps: SharedInfraDeps; calls: Call[] } { + const calls: Call[] = []; + const ok = { success: true, stdout: "", stderr: "" }; + const deps: SharedInfraDeps = { + async run(command, args, stdin) { + calls.push({ command, args, stdin }); + const sub = args[0]; + const handler = sub === "inspect" ? handlers.inspect + : sub === "run" ? handlers.run + : sub === "start" ? handlers.start + : sub === "exec" ? () => handlers.exec?.(args, stdin) ?? ok + : sub === "stop" ? handlers.stop + : undefined; + return handler?.(args) ?? ok; + }, + }; + return { deps, calls }; +} + +let stateDir: string; +function withState(): SharedInfraDeps { + const { deps } = fakeDocker(); + return { ...deps, stateDir }; +} + +function tenant(over: Partial = {}): TenantRef { + return { + project: "aaa111", + projectName: "alpha", + scenario: "development", + service: "db", + name: "alpha_db", + ...over, + }; +} + +const pgProfile: SharedPoolProfile = { kind: "postgres", version: "18", image: "postgres:18-alpine" }; +const redisProfile: SharedPoolProfile = { kind: "redis", version: "7", image: "redis:7-alpine" }; + +beforeEach(() => { + stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "pegas-shared-")); +}); + +describe("issue #29: классификация образов в пулы", () => { + it("postgres и его производные — пул postgres", () => { + expect(classifySharedImage("postgres:18-alpine")).toEqual({ kind: "postgres", version: "18", image: "postgres:18-alpine" }); + expect(classifySharedImage("postgres:16")?.version).toBe("16"); + expect(classifySharedImage("pgvector/pgvector:pg17")?.kind).toBe("postgres"); + expect(classifySharedImage("timescale/timescaledb:2.14-pg16")?.kind).toBe("postgres"); + }); + + it("redis и valkey — пул redis", () => { + expect(classifySharedImage("redis:7-alpine")).toEqual({ kind: "redis", version: "7", image: "redis:7-alpine" }); + expect(classifySharedImage("valkey/valkey:8")?.kind).toBe("redis"); + }); + + it("реестр и namespace в имени образа не мешают классификации", () => { + const p = classifySharedImage("registry.example.com:5000/team/postgres:18.2-alpine"); + expect(p).toEqual({ kind: "postgres", version: "18.2", image: "registry.example.com:5000/team/postgres:18.2-alpine" }); + }); + + it("digest не влияет на версию", () => { + expect(classifySharedImage("postgres:18@sha256:abcd")?.version).toBe("18"); + }); + + it("образ без тега считается latest: разные версии в один пул не смешиваются", () => { + expect(classifySharedImage("postgres")?.version).toBe("latest"); + expect(sharedPoolKey(classifySharedImage("postgres")!, "development")) + .not.toBe(sharedPoolKey(classifySharedImage("postgres:18")!, "development")); + }); + + it("образы приложений и собранные pegas-образы не разделяемы", () => { + expect(classifySharedImage("pegas/abc123/web:hash123")).toBeNull(); + expect(classifySharedImage("node:22-alpine")).toBeNull(); + expect(classifySharedImage("nginx:1.27")).toBeNull(); + expect(classifySharedImage(undefined)).toBeNull(); + expect(classifySharedImage("")).toBeNull(); + expect(classifySharedImage(" ")).toBeNull(); + }); +}); + +describe("issue #29: идентичность пула", () => { + it("пул привязан к сценарию: dev и production не делят инстанс", () => { + // Общий инстанс для dev и production означал бы доступ dev-арендатора + // к production-данным. + const dev = sharedPoolName(pgProfile, "development"); + const prod = sharedPoolName(pgProfile, "production"); + expect(dev).toBe("pegas-shared-postgres-18-development"); + expect(prod).toBe("pegas-shared-postgres-18-production"); + expect(dev).not.toBe(prod); + expect(sharedPoolKey(pgProfile, "development")).toBe("postgres-18-development"); + }); + + it("имя тома стабильно и не зависит от compose-проекта", () => { + expect(sharedVolumeName(pgProfile, "development")).toBe("pegas-shared-postgres-18-development-data"); + expect(sharedVolumeName(redisProfile, "production")).toBe("pegas-shared-redis-7-production-data"); + }); + + it("порты по умолчанию соответствуют сервису", () => { + expect(sharedPoolPort("postgres")).toBe(5432); + expect(sharedPoolPort("redis")).toBe(6379); + expect(sharedPoolPort("postgres", { defaultPort: () => 15432 })).toBe(15432); + }); +}); + +describe("issue #29: реестр арендаторов", () => { + it("пустой реестр при отсутствии файла", () => { + expect(loadSharedState({ stateDir })).toEqual({ pools: {} }); + }); + + it("сохранение и чтение round-trip", () => { + const deps = { stateDir }; + let state = loadSharedState(deps); + const key = sharedPoolKey(pgProfile, "development"); + state = registerTenant(state, key, tenant()); + saveSharedState(state, deps); + + const reloaded = loadSharedState(deps); + expect(countTenants(reloaded, key)).toBe(1); + expect(reloaded.pools[key][0].name).toBe("alpha_db"); + }); + + it("файл реестра закрыт от других пользователей (0600): там имена арендаторов", () => { + const deps = { stateDir }; + saveSharedState(registerTenant(loadSharedState(deps), "postgres-18-development", tenant()), deps); + const file = path.join(stateDir, "state.json"); + const mode = fs.statSync(file).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it("повторная регистрация того же арендатора не дублирует запись", () => { + const key = sharedPoolKey(pgProfile, "development"); + let state = loadSharedState({ stateDir }); + state = registerTenant(state, key, tenant()); + state = registerTenant(state, key, tenant()); + expect(countTenants(state, key)).toBe(1); + }); + + it("один и тот же сервис в двух checkout'ах — два разных арендатора", () => { + const key = sharedPoolKey(pgProfile, "development"); + let state = loadSharedState({ stateDir }); + state = registerTenant(state, key, tenant({ project: "aaa111", name: "alpha_db" })); + state = registerTenant(state, key, tenant({ project: "bbb222", projectName: "beta", name: "beta_db" })); + expect(countTenants(state, key)).toBe(2); + }); + + it("release убирает только свою запись, чужие арендаторы остаются", () => { + const key = sharedPoolKey(pgProfile, "development"); + let state = loadSharedState({ stateDir }); + state = registerTenant(state, key, tenant({ project: "aaa111" })); + state = registerTenant(state, key, tenant({ project: "bbb222", projectName: "beta" })); + state = releaseTenant(state, key, tenant({ project: "aaa111" })); + expect(countTenants(state, key)).toBe(1); + expect(state.pools[key][0].project).toBe("bbb222"); + }); + + it("опустевший пул исчезает из реестра", () => { + const key = sharedPoolKey(pgProfile, "development"); + let state = loadSharedState({ stateDir }); + state = registerTenant(state, key, tenant()); + state = releaseTenant(state, key, tenant()); + expect(state.pools[key]).toBeUndefined(); + }); + + it("releaseProjectTenants снимает все записи проекта, включая несколько сервисов", () => { + const key = sharedPoolKey(pgProfile, "development"); + let state = loadSharedState({ stateDir }); + state = registerTenant(state, key, tenant({ project: "aaa111", service: "db" })); + state = registerTenant(state, key, tenant({ project: "aaa111", service: "analytics", name: "alpha_analytics" })); + state = registerTenant(state, key, tenant({ project: "bbb222", projectName: "beta", name: "beta_db" })); + state = releaseProjectTenants(state, "aaa111"); + expect(countTenants(state, key)).toBe(1); + expect(state.pools[key][0].project).toBe("bbb222"); + }); + + it("фильтр по сценарию не трогает записи других сценариев", () => { + const devKey = sharedPoolKey(pgProfile, "development"); + const prodKey = sharedPoolKey(pgProfile, "production"); + let state = loadSharedState({ stateDir }); + state = registerTenant(state, devKey, tenant({ scenario: "development" })); + state = registerTenant(state, prodKey, tenant({ scenario: "production" })); + state = releaseProjectTenants(state, "aaa111", "development"); + expect(countTenants(state, devKey)).toBe(0); + expect(countTenants(state, prodKey)).toBe(1); + }); + + it("повреждённый файл реестра не роняет загрузку", () => { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, "state.json"), "{ не json", "utf-8"); + expect(loadSharedState({ stateDir })).toEqual({ pools: {} }); + }); + + it("записи без обязательных полей отбрасываются при загрузке", () => { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + path.join(stateDir, "state.json"), + JSON.stringify({ pools: { "postgres-18-development": [{ project: "a" }, tenant()] } }), + "utf-8" + ); + const state = loadSharedState({ stateDir }); + expect(state.pools["postgres-18-development"].length).toBe(1); + expect(state.pools["postgres-18-development"][0].service).toBe("db"); + }); +}); + +describe("issue #29: запуск инстанса пула", () => { + it("создаёт контейнер с томом, метками и loopback-публикацией", async () => { + const { deps, calls } = fakeDocker({ + inspect: () => ({ success: false, stdout: "", stderr: "No such object" }), + }); + const full: SharedInfraDeps = { ...deps, stateDir }; + + const res = await ensureSharedPool(full, { + profile: pgProfile, scenario: "development", publishHostPort: true, hostPort: 5432, + }); + + expect(res.created).toBe(true); + expect(res.host).toBe("pegas-shared-postgres-18-development"); + expect(res.port).toBe(5432); + + const run = calls.find((c) => c.args[0] === "run")!; + const joined = run.args.join(" "); + // Метка пула вместо pegas.project: removeProjectContainers удаляет по + // pegas.project, поэтому `pegas down` одного проекта не должен снести пул. + expect(joined).toContain(`${SHARED_POOL_LABEL}=postgres-18-development`); + expect(joined).toContain(`${SHARED_SCOPE_LABEL}=shared`); + expect(joined).not.toContain("label pegas.project="); + expect(run.args).not.toContain("pegas.project"); + // Том с явным именем: данные не привязаны к compose-проекту. + expect(joined).toContain("--volume pegas-shared-postgres-18-development-data:/var/lib/postgresql"); + // Точка монтирования postgres:18+ (issue #26), не legacy .../data + expect(joined).not.toContain("/var/lib/postgresql/data"); + // Публикация только на loopback + expect(joined).toContain("--publish 127.0.0.1:5432:5432"); + expect(joined).not.toContain("--publish 0.0.0.0"); + expect(run.args[run.args.length - 1]).toBe("postgres:18-alpine"); + }); + + it("пароль суперпользователя передаётся файлом, а не через argv", async () => { + const { deps, calls } = fakeDocker({ + inspect: () => ({ success: false, stdout: "", stderr: "No such object" }), + }); + const full: SharedInfraDeps = { ...deps, stateDir }; + await ensureSharedPool(full, { profile: pgProfile, scenario: "development", publishHostPort: false }); + + const run = calls.find((c) => c.args[0] === "run")!; + const joined = run.args.join(" "); + expect(joined).toContain("POSTGRES_PASSWORD_FILE=/run/secrets/pegas-superuser"); + expect(joined).toContain(":ro"); + // Значение секрета не должно появляться в argv docker run + const secretPath = path.join(stateDir, "secrets", "postgres-18-development-superuser"); + const secret = fs.readFileSync(secretPath, "utf-8").trim(); + expect(secret.length).toBeGreaterThan(20); + expect(joined).not.toContain(secret); + expect(joined).not.toContain("POSTGRES_PASSWORD="); + }); + + it("файл секрета имеет режим 0600 и переиспользуется между запусками", async () => { + const { deps } = fakeDocker({ inspect: () => ({ success: false, stdout: "", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const first = ensureSuperuserSecret(full, pgProfile, "development"); + const secret1 = fs.readFileSync(first, "utf-8").trim(); + expect(fs.statSync(first).mode & 0o777).toBe(0o600); + + const second = ensureSuperuserSecret(full, pgProfile, "development"); + expect(second).toBe(first); + // Тот же пароль: иначе пересозданный пул не сошёлся бы с инициализированной базой + expect(fs.readFileSync(second, "utf-8").trim()).toBe(secret1); + }); + + it("redis-пул монтирует /data и не требует пароля суперпользователя", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: false, stdout: "", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const res = await ensureSharedPool(full, { profile: redisProfile, scenario: "production", publishHostPort: false }); + expect(res.host).toBe("pegas-shared-redis-7-production"); + const joined = calls.find((c) => c.args[0] === "run")!.args.join(" "); + expect(joined).toContain("--volume pegas-shared-redis-7-production-data:/data"); + expect(joined).not.toContain("POSTGRES_PASSWORD_FILE"); + expect(joined).not.toContain("--publish"); + }); + + it("уже запущенный пул не пересоздаётся: docker run не вызывается", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "true", stderr: "" }) }); + const res = await ensureSharedPool({ ...deps, stateDir }, { + profile: pgProfile, scenario: "development", publishHostPort: true, + }); + expect(res.created).toBe(false); + expect(calls.some((c) => c.args[0] === "run")).toBe(false); + expect(calls.some((c) => c.args[0] === "start")).toBe(false); + }); + + it("остановленный пул запускается, данные сохраняются", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "false", stderr: "" }) }); + const res = await ensureSharedPool({ ...deps, stateDir }, { + profile: pgProfile, scenario: "development", publishHostPort: false, + }); + expect(res.created).toBe(false); + const started = calls.find((c) => c.args[0] === "start"); + expect(started?.args).toContain("pegas-shared-postgres-18-development"); + // Пересоздание удалило бы том: docker run обязан отсутствовать + expect(calls.some((c) => c.args[0] === "run")).toBe(false); + }); + + it("ошибка создания пула — явное исключение с причиной", async () => { + const { deps } = fakeDocker({ + inspect: () => ({ success: false, stdout: "", stderr: "" }), + run: () => ({ success: false, stdout: "", stderr: "port is already allocated" }), + }); + await expect( + ensureSharedPool({ ...deps, stateDir }, { profile: pgProfile, scenario: "development", publishHostPort: true }) + ).rejects.toThrow(/port is already allocated/); + }); + + it("ошибка запуска остановленного пула — явное исключение", async () => { + const { deps } = fakeDocker({ + inspect: () => ({ success: true, stdout: "false", stderr: "" }), + start: () => ({ success: false, stdout: "", stderr: "device or resource busy" }), + }); + await expect( + ensureSharedPool({ ...deps, stateDir }, { profile: pgProfile, scenario: "development", publishHostPort: false }) + ).rejects.toThrow(/device or resource busy/); + }); +}); + +describe("issue #29: SQL подготовки арендатора", () => { + it("создаёт роль и базу идемпотентно (IF NOT EXISTS + WHERE NOT EXISTS)", () => { + const sql = buildPostgresProvisionSql("alpha_db", "secret-pw"); + expect(sql).toContain("IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'alpha_db')"); + expect(sql).toContain("WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'alpha_db')"); + expect(sql).toContain("\\gexec"); + expect(sql).toContain('GRANT ALL PRIVILEGES ON DATABASE "alpha_db" TO "alpha_db"'); + }); + + it("пароль экранируется как SQL-литерал, а не подставляется сырым", () => { + const sql = buildPostgresProvisionSql("t", "pw'with\"quote"); + expect(sql).toContain("'pw''with\"quote'"); + // Сырое значение с одиночной кавычкой разорвало бы SQL и позволило инъекцию + expect(sql).not.toContain("'pw'with"); + }); + + it("format('%I'/'%L') используется вместо склейки идентификаторов", () => { + const sql = buildPostgresProvisionSql("t", "pw"); + expect(sql).toContain("EXECUTE format('CREATE ROLE %I WITH LOGIN PASSWORD %L'"); + }); + + it("без секрета роль создаётся без PASSWORD", () => { + const sql = buildPostgresProvisionSql("t", undefined); + expect(sql).toContain("CREATE ROLE %I WITH LOGIN"); + expect(sql).not.toContain("PASSWORD"); + }); + + it("перевод строки в пароле отклоняется: он разорвал бы SQL-поток", () => { + expect(() => buildPostgresProvisionSql("t", "a\nb")).toThrow(/перевод/); + }); + + it("опасные идентификаторы отклоняются до построения SQL", () => { + for (const bad of ["1abc", "a-b", "a b", "a;b", 'a"b', "a'b", "", "drop database x--"]) { + expect(() => quoteSqlIdentifier(bad)).toThrow(); + expect(() => buildPostgresProvisionSql(bad, "pw")).toThrow(); + } + }); + + it("допустимые идентификаторы проходят", () => { + expect(quoteSqlIdentifier("alpha_db")).toBe('"alpha_db"'); + expect(quoteSqlIdentifier("_x1")).toBe('"_x1"'); + expect(quoteSqlIdentifier("Alpha")).toBe('"Alpha"'); + }); + + it("quoteSqlLiteral удваивает кавычки", () => { + expect(quoteSqlLiteral("a'b")).toBe("'a''b'"); + expect(quoteSqlLiteral("plain")).toBe("'plain'"); + }); +}); + +describe("issue #29: подготовка арендатора и подсчёт ссылок", () => { + it("postgres: SQL уходит в stdin docker exec -i, пароль не попадает в argv", async () => { + const { deps, calls } = fakeDocker({ exec: () => ({ success: true, stdout: "", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const key = sharedPoolKey(pgProfile, "development"); + + const res = await provisionSharedTenant( + full, key, pgProfile, "development", tenant(), { name: "alpha_db", secret: "DB_PASSWORD" }, "s3cret" + ); + + expect(res.created).toBe(true); + const exec = calls.find((c) => c.args[0] === "exec")!; + expect(exec.args).toContain("-i"); + expect(exec.args).not.toContain("-t"); + expect(exec.args).toContain("pegas-shared-postgres-18-development"); + expect(exec.args.join(" ")).toContain("psql"); + // Секрет только в stdin + expect(exec.stdin).toContain("'s3cret'"); + expect(exec.args.join(" ")).not.toContain("s3cret"); + }); + + it("повторная подготовка помечает арендатора как существующего", async () => { + const { deps } = fakeDocker({ exec: () => ({ success: true, stdout: "", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const key = sharedPoolKey(pgProfile, "development"); + const first = await provisionSharedTenant(full, key, pgProfile, "development", tenant(), { name: "alpha_db" }, undefined); + const second = await provisionSharedTenant(full, key, pgProfile, "development", tenant(), { name: "alpha_db" }, undefined); + expect(first.created).toBe(true); + expect(second.created).toBe(false); + expect(countTenants(loadSharedState(full), key)).toBe(1); + }); + + it("redis: изоляция префиксом ключей, SQL не выполняется", async () => { + const { deps, calls } = fakeDocker(); + const full: SharedInfraDeps = { ...deps, stateDir }; + const key = sharedPoolKey(redisProfile, "development"); + + const res = await provisionSharedTenant( + full, key, redisProfile, "development", + tenant({ service: "cache", name: "alpha" }), { name: "alpha" }, undefined + ); + + expect(res.name).toBe("alpha"); + expect(res.skipped).toContain("префикс"); + expect(calls.some((c) => c.args[0] === "exec")).toBe(false); + expect(countTenants(loadSharedState(full), key)).toBe(1); + }); + + it("ошибка psql пробрасывается с именем пула и арендатора", async () => { + const { deps } = fakeDocker({ + exec: () => ({ success: false, stdout: "", stderr: 'ERROR: role "alpha_db" already exists' }), + }); + await expect( + provisionSharedTenant({ ...deps, stateDir }, "postgres-18-development", pgProfile, "development", tenant(), { name: "alpha_db" }, undefined) + ).rejects.toThrow(/pegas-shared-postgres-18-development/); + }); + + it("недопустимое имя арендатора отклоняется с путём до поля контракта", async () => { + const { deps } = fakeDocker(); + await expect( + provisionSharedTenant( + { ...deps, stateDir }, "postgres-18-development", pgProfile, "development", + tenant({ service: "db" }), { name: "bad-name" }, undefined + ) + ).rejects.toThrow(/services\.db/); + }); + + it("пустой пул останавливается, данные не удаляются", async () => { + const { deps, calls } = fakeDocker({ + inspect: () => ({ success: true, stdout: "true", stderr: "" }), + }); + const res = await stopSharedPoolIfUnused({ ...deps, stateDir }, "postgres-18-development", "pegas-shared-postgres-18-development"); + expect(res.stopped).toBe(true); + expect(calls.some((c) => c.args[0] === "stop")).toBe(true); + // rm -v уничтожил бы данные арендаторов: только stop + expect(calls.some((c) => c.args[0] === "rm")).toBe(false); + }); + + it("пул с оставшимися арендаторами удерживается", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "true", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const key = "postgres-18-development"; + let state = loadSharedState(full); + state = registerTenant(state, key, tenant({ project: "aaa111" })); + state = registerTenant(state, key, tenant({ project: "bbb222", projectName: "beta", name: "beta_db" })); + // Один проект ушёл, второй остался: пул обязан продолжить работу. + state = releaseTenant(state, key, tenant({ project: "aaa111" })); + saveSharedState(state, full); + + const res = await stopSharedPoolIfUnused(full, key, "pegas-shared-postgres-18-development"); + expect(res.stopped).toBe(false); + expect(res.reason).toContain("1"); + expect(calls.some((c) => c.args[0] === "stop")).toBe(false); + }); + + it("уже остановленный пул не останавливается повторно", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "false", stderr: "" }) }); + const res = await stopSharedPoolIfUnused({ ...deps, stateDir }, "postgres-18-development", "pegas-shared-postgres-18-development"); + expect(res.stopped).toBe(false); + expect(res.reason).toContain("уже остановлен"); + expect(calls.some((c) => c.args[0] === "stop")).toBe(false); + }); + + it("down проекта: ссылки сняты, опустевший пул остановлен", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "true", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const key = sharedPoolKey(pgProfile, "development"); + saveSharedState(registerTenant(loadSharedState(full), key, tenant()), full); + + const res = await releaseProjectSharedPools(full, "aaa111", "development"); + expect(res.released).toBe(1); + expect(res.stoppedPools).toEqual(["pegas-shared-postgres-18-development"]); + expect(countTenants(loadSharedState(full), key)).toBe(0); + expect(calls.some((c) => c.args[0] === "stop")).toBe(true); + }); + + it("down проекта не останавливает пул, которым пользуется другой проект", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "true", stderr: "" }) }); + const full: SharedInfraDeps = { ...deps, stateDir }; + const key = sharedPoolKey(pgProfile, "development"); + let state = loadSharedState(full); + state = registerTenant(state, key, tenant({ project: "aaa111" })); + state = registerTenant(state, key, tenant({ project: "bbb222", projectName: "beta", name: "beta_db" })); + saveSharedState(state, full); + + const res = await releaseProjectSharedPools(full, "aaa111", "development"); + expect(res.released).toBe(1); + expect(res.stoppedPools).toEqual([]); + expect(countTenants(loadSharedState(full), key)).toBe(1); + expect(calls.some((c) => c.args[0] === "stop")).toBe(false); + }); + + it("проект без арендаторов: реестр и docker не трогаются", async () => { + const { deps, calls } = fakeDocker({ inspect: () => ({ success: true, stdout: "true", stderr: "" }) }); + const res = await releaseProjectSharedPools({ ...deps, stateDir }, "nobody"); + expect(res).toEqual({ released: 0, stoppedPools: [] }); + expect(calls.length).toBe(0); + }); +}); diff --git a/src/shared-infra.ts b/src/shared-infra.ts new file mode 100644 index 0000000..b66f029 --- /dev/null +++ b/src/shared-infra.ts @@ -0,0 +1,616 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import child_process from "node:child_process"; +import { randomBytes } from "node:crypto"; +import type { SharedTenantConfig } from "./types.js"; + +/** + * Разделяемые инфраструктурные пулы (issue #29). + * + * Один инстанс PostgreSQL/Redis обслуживает несколько проектов-арендаторов: + * вместо N процессов СУБД — один, а изоляция обеспечивается отдельной базой и + * ролью (postgres) либо префиксом ключей (redis). + * + * Проектировочные решения, отличающиеся от черновика issue: + * + * 1. **Пул привязан к сценарию.** `pegas-shared-postgres-18-development` и + * `pegas-shared-postgres-18-production` — разные инстансы. Общая база для dev + * и production означала бы, что dev-арендатор видит production-данные. + * + * 2. **Пулы не несут метку `pegas.project`.** `removeProjectContainers` удаляет + * контейнеры по этой метке, поэтому `pegas down` одного проекта снёс бы общую + * базу. Вместо неё — `pegas.shared-pool`. + * + * 3. **Пароль не попадает в argv.** SQL передаётся через stdin `docker exec -i`: + * значение не видно в `ps` и не оседает в истории команд. + * + * 4. **Данные арендатора никогда не удаляются.** `down` только уменьшает счётчик + * ссылок и останавливает пустой инстанс; том с данными сохраняется. + * + * 5. **Имя арендатора обязано быть объявлено в контракте** (`shared.name`). + * Угадывание имени из `projectName` привело бы к тому, что переименование + * проекта создало бы вторую базу и «потеряло» данные. + */ + +export type SharedPoolKind = "postgres" | "redis"; + +/** Профиль пула: какой образ и какую мажорную версию он представляет. */ +export interface SharedPoolProfile { + kind: SharedPoolKind; + /** Мажорная версия образа: пулы разных версий не смешиваются. */ + version: string; + /** Исходное значение image из контракта. */ + image: string; +} + +/** Результат команды Docker. Совместим с CommandResult из docker.ts. */ +export interface SharedCommandResult { + success: boolean; + stdout: string; + stderr: string; +} + +/** Зависимости модуля: инжектируемый исполнитель команд и каталог состояния. */ +export interface SharedInfraDeps { + /** Выполняет команду и возвращает результат (не бросает исключение при ненулевом коде). */ + run: (command: string, args: string[], stdin?: string) => Promise; + /** Каталог состояния пулов (по умолчанию ~/.pegas/shared). */ + stateDir?: string; + /** Порт по умолчанию для профиля. */ + defaultPort?: (kind: SharedPoolKind) => number; +} + +/** + * Реальный исполнитель команд Docker со stdin (issue #29). + * + * `runCommand` из docker.ts stdin не поддерживает, а он нужен принципиально: + * SQL подготовки арендатора содержит пароль и обязан идти через stdin + * `docker exec -i`, а не через argv, где его видно в `ps`. Поэтому у пулов — + * свой исполнитель на spawn с тремя pipe-потоками; при ненулевом коде не + * бросает, а возвращает { success: false, stderr } — как SharedCommandResult. + */ +export function runDockerWithStdin(command: string, args: string[], stdin?: string): Promise { + return new Promise((resolve) => { + const child = child_process.spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); + child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + child.on("error", (err) => { + resolve({ success: false, stdout: stdout.trim(), stderr: (stderr.trim() || err.message).trim() }); + }); + child.on("close", (code) => { + resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + child.stdin.on("error", () => {}); + child.stdin.end(stdin); + }); +} + +/** Зависимости пулов по умолчанию: реальный Docker, состояние в ~/.pegas/shared. */ +export function createSharedInfraDeps(overrides: Partial = {}): SharedInfraDeps { + return { run: runDockerWithStdin, ...overrides }; +} + +/** + * Ожидание готовности свежесозданного пула (issue #29). + * + * `docker run -d` возвращается до инициализации СУБД: postgres на пустом томе + * несколько секунд разворачивает кластер, и немедленный `docker exec psql` + * упал бы. Опрашиваем родной readiness-инструмент образа внутри контейнера + * (pg_isready / redis-cli ping) до успеха или таймаута. + */ +export async function waitForSharedPoolReady( + deps: SharedInfraDeps, + profile: SharedPoolProfile, + scenario: string, + timeoutMs = 30_000 +): Promise { + const containerName = sharedPoolName(profile, scenario); + const probe = profile.kind === "postgres" + ? ["exec", containerName, "pg_isready", "-U", "postgres", "-q"] + : ["exec", containerName, "redis-cli", "ping"]; + const deadline = Date.now() + timeoutMs; + for (;;) { + const res = await deps.run("docker", probe); + if (res.success) return; + if (Date.now() >= deadline) { + throw new Error(`Разделяемый пул ${containerName} не стал готов за ${Math.round(timeoutMs / 1000)}с: ${res.stderr || res.stdout}`); + } + await new Promise((r) => setTimeout(r, 500)); + } +} + +/** Ссылка на арендатора пула — запись в реестре состояния. */ +export interface TenantRef { + /** runtimeId проекта (уникальность checkout'а). */ + project: string; + /** Имя проекта из контракта. */ + projectName: string; + scenario: string; + /** Имя сервиса в контракте проекта-арендатора. */ + service: string; + /** Имя арендатора (база/роль или префикс ключей). */ + name: string; +} + +export interface SharedPoolState { + /** Ключ пула → список арендаторов. */ + pools: Record; +} + +/** Метка контейнера разделяемого пула. */ +export const SHARED_POOL_LABEL = "pegas.shared-pool"; + +/** Метка роли контейнера в экосистеме Pegas. */ +export const SHARED_SCOPE_LABEL = "pegas.scope"; + +/** + * Определяет профиль разделяемого пула по образу. + * Возвращает null, если образ не является поддерживаемой инфраструктурой: + * разделять можно только общеизвестные stateful-сервисы. + */ +export function classifySharedImage(image: string | undefined): SharedPoolProfile | null { + if (!image) return null; + const raw = image.trim(); + if (!raw) return null; + // Локальные и собранные образы не разделяемы: их содержимое принадлежит проекту. + if (raw.startsWith("pegas/")) return null; + + // Образ вида [registry/][namespace/]name[:tag][@digest] + const withoutDigest = raw.split("@")[0]; + const withoutTag = withoutDigest.replace(/:[^/]*$/, ""); + const name = withoutTag.split("/").pop() ?? ""; + const tagMatch = withoutDigest.match(/:([^/]+)$/); + const tag = tagMatch ? tagMatch[1] : "latest"; + + // Мажорная версия: "18-alpine" → "18", "7.4" → "7.4", "latest" → "latest" + const majorMatch = tag.match(/^(\d+(?:\.\d+)?)/); + const version = majorMatch ? majorMatch[1] : tag; + + if (/^(postgres|pgvector|timescale.*|postgis.*)$/i.test(name)) { + return { kind: "postgres", version, image: raw }; + } + if (/^(redis|valkey)$/i.test(name)) { + return { kind: "redis", version, image: raw }; + } + return null; +} + +/** Ключ пула: идентичность инстанса (вид, версия, сценарий). */ +export function sharedPoolKey(profile: SharedPoolProfile, scenario: string): string { + return `${profile.kind}-${profile.version}-${scenario}`; +} + +/** Имя контейнера и сетевого алиаса пула. */ +export function sharedPoolName(profile: SharedPoolProfile, scenario: string): string { + return `pegas-shared-${sharedPoolKey(profile, scenario)}`; +} + +/** Имя тома данных пула. Том не привязан к compose-проекту и переживает переименования. */ +export function sharedVolumeName(profile: SharedPoolProfile, scenario: string): string { + return `pegas-shared-${sharedPoolKey(profile, scenario)}-data`; +} + +/** Логический порт сервиса внутри пула. */ +export function sharedPoolPort(kind: SharedPoolKind, deps?: Pick): number { + if (deps?.defaultPort) return deps.defaultPort(kind); + return kind === "postgres" ? 5432 : 6379; +} + +/** Каталог состояния пулов. */ +export function getSharedStateDir(deps?: Pick): string { + if (deps?.stateDir) return deps.stateDir; + return path.join(os.homedir(), ".pegas", "shared"); +} + +/** Путь файла реестра арендаторов. */ +export function getSharedStatePath(deps?: Pick): string { + return path.join(getSharedStateDir(deps), "state.json"); +} + +/** Читает реестр арендаторов. Отсутствующий или повреждённый файл — пустой реестр. */ +export function loadSharedState(deps?: Pick): SharedPoolState { + const file = getSharedStatePath(deps); + try { + const raw = fs.readFileSync(file, "utf-8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && parsed.pools && typeof parsed.pools === "object") { + const pools: Record = {}; + for (const [key, list] of Object.entries(parsed.pools as Record)) { + if (Array.isArray(list)) pools[key] = list.filter(isTenantRef); + } + return { pools }; + } + } catch { + // Файл отсутствует или повреждён: считаем реестр пустым. Потеря реестра + // не удаляет данные — пул просто продолжит работать, а count ссылок + // восстановится при следующих деплоях. + } + return { pools: {} }; +} + +function isTenantRef(value: unknown): value is TenantRef { + return ( + typeof value === "object" && value !== null && + typeof (value as TenantRef).project === "string" && + typeof (value as TenantRef).scenario === "string" && + typeof (value as TenantRef).service === "string" && + typeof (value as TenantRef).name === "string" + ); +} + +/** Атомарно пишет реестр арендаторов. */ +export function saveSharedState(state: SharedPoolState, deps?: Pick): void { + const file = getSharedStatePath(deps); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 }); + fs.renameSync(tmp, file); +} + +/** Ключ записи арендатора: проект + сценарий + сервис. */ +function tenantKey(ref: TenantRef): string { + return `${ref.project}::${ref.scenario}::${ref.service}`; +} + +/** Добавляет арендатора в реестр (идемпотентно). */ +export function registerTenant(state: SharedPoolState, poolKey: string, ref: TenantRef): SharedPoolState { + const existing = state.pools[poolKey] ?? []; + const key = tenantKey(ref); + const withoutSelf = existing.filter((t) => tenantKey(t) !== key); + return { pools: { ...state.pools, [poolKey]: [...withoutSelf, ref] } }; +} + +/** + * Удаляет арендатора из реестра. Возвращает новый реестр. + * + * Удаляется только запись ссылки: данные арендатора (база, роль, ключи) остаются + * в томе пула, поэтому повторный deploy поднимает проект на прежних данных. + */ +export function releaseTenant(state: SharedPoolState, poolKey: string, ref: TenantRef): SharedPoolState { + const existing = state.pools[poolKey] ?? []; + const key = tenantKey(ref); + const remaining = existing.filter((t) => tenantKey(t) !== key); + const pools = { ...state.pools }; + if (remaining.length === 0) delete pools[poolKey]; + else pools[poolKey] = remaining; + return { pools }; +} + +/** Удаляет все записи проекта (используется `pegas down`). */ +export function releaseProjectTenants(state: SharedPoolState, project: string, scenario?: string): SharedPoolState { + const pools: Record = {}; + for (const [key, list] of Object.entries(state.pools)) { + const remaining = list.filter((t) => { + if (t.project !== project) return true; + if (scenario && t.scenario !== scenario) return true; + return false; + }); + if (remaining.length > 0) pools[key] = remaining; + } + return { pools }; +} + +/** Количество арендаторов пула. */ +export function countTenants(state: SharedPoolState, poolKey: string): number { + return (state.pools[poolKey] ?? []).length; +} + +export interface EnsureSharedPoolOptions { + profile: SharedPoolProfile; + scenario: string; + /** Публиковать порт на хост (development: host-приложения ходят в 127.0.0.1:port). */ + publishHostPort: boolean; + /** Хостовый порт публикации. */ + hostPort?: number; + /** Логический порт сервиса внутри пула. */ + port?: number; +} + +export interface EnsureSharedPoolResult { + /** Сетевой алиас пула в pegas-network. */ + host: string; + /** Логический порт сервиса. */ + port: number; + /** Был ли контейнер создан в этом вызове. */ + created: boolean; + /** Имя контейнера. */ + containerName: string; +} + +/** Каталог файлов-секретов, монтируемых в контейнеры пулов. */ +function getSecretsDir(deps: Pick): string { + return path.join(getSharedStateDir(deps), "secrets"); +} + +/** + * Генерирует (однократно) пароль суперпользователя пула PostgreSQL. + * + * Значение пишется в файл с режимом 0600 и монтируется в контейнер как + * `POSTGRES_PASSWORD_FILE`. Передавать пароль через `--env` нельзя: argv + * `docker run` виден другим локальным пользователям в `ps`, а образ PostgreSQL + * без пароля вообще не запускается (проверено: контейнер падает с «superuser + * password is not specified»). + * + * Файл сохраняется между запусками, поэтому пересоздание пула на том же томе + * не расходится с уже инициализированной базой: пароль меняется только вместе + * с удалением каталога данных. + */ +export function ensureSuperuserSecret( + deps: Pick, + profile: SharedPoolProfile, + scenario: string +): string { + const dir = getSecretsDir(deps); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${sharedPoolKey(profile, scenario)}-superuser`); + try { + const existing = fs.readFileSync(file, "utf-8").trim(); + if (existing) return file; + } catch { + // Файла нет или он не читается — создаём заново. + } + const password = randomBytes(24).toString("base64url"); + // mode игнорируется, если файл уже существует, поэтому выставляем права явно. + fs.writeFileSync(file, `${password}\n`, { encoding: "utf-8", mode: 0o600 }); + fs.chmodSync(file, 0o600); + return file; +} + +/** + * Гарантирует, что инстанс пула запущен и доступен в сети pegas-network. + * Идемпотентно: повторный вызов не пересоздаёт контейнер и не трогает данные. + */ +export async function ensureSharedPool( + deps: SharedInfraDeps, + options: EnsureSharedPoolOptions +): Promise { + const { profile, scenario } = options; + const containerName = sharedPoolName(profile, scenario); + const port = options.port ?? sharedPoolPort(profile.kind, deps); + + const inspect = await deps.run("docker", ["inspect", containerName, "--format", "{{.State.Running}}"]); + if (inspect.success && inspect.stdout.trim() === "true") { + return { host: containerName, port, created: false, containerName }; + } + if (inspect.success) { + // Контейнер существует, но остановлен: запускаем (данные в томе сохранены). + const started = await deps.run("docker", ["start", containerName]); + if (started.success) { + return { host: containerName, port, created: false, containerName }; + } + throw new Error( + `Не удалось запустить разделяемый пул ${containerName}: ${started.stderr || started.stdout}` + ); + } + + const volume = sharedVolumeName(profile, scenario); + const dataPath = profile.kind === "postgres" ? "/var/lib/postgresql" : "/data"; + const args = [ + "run", "-d", + "--name", containerName, + "--network", "pegas-network", + "--network-alias", containerName, + "--restart", "unless-stopped", + "--label", `${SHARED_POOL_LABEL}=${sharedPoolKey(profile, scenario)}`, + "--label", `${SHARED_SCOPE_LABEL}=shared`, + "--label", `pegas.shared-kind=${profile.kind}`, + "--label", `pegas.shared-version=${profile.version}`, + "--label", `pegas.scenario=${scenario}`, + // Том с явным именем: данные пула не привязаны ни к одному compose-проекту. + "--volume", `${volume}:${dataPath}`, + ]; + + if (options.publishHostPort) { + // Публикация только на loopback: пул не должен быть доступен извне. + const hostPort = options.hostPort ?? port; + args.push("--publish", `127.0.0.1:${hostPort}:${port}`); + } + + if (profile.kind === "postgres") { + const secretFile = ensureSuperuserSecret(deps, profile, scenario); + const mountPath = "/run/secrets/pegas-superuser"; + // Пароль суперпользователя — из файла, а не из argv: `docker run --env` виден + // другим локальным пользователям в `ps`. Сеть требует scram-sha-256 + // (проверено на postgres:18-alpine: `host all all all scram-sha-256`), + // поэтому снаружи инстанс без пароля не пускает; подготовка арендаторов идёт + // через unix-сокет внутри контейнера (`local all all trust`). + args.push("--volume", `${secretFile}:${mountPath}:ro`); + args.push("--env", `POSTGRES_PASSWORD_FILE=${mountPath}`); + } + + args.push(profile.image); + + const created = await deps.run("docker", args); + if (!created.success) { + throw new Error( + `Не удалось создать разделяемый пул ${containerName}: ${created.stderr || created.stdout}` + ); + } + + return { host: containerName, port, created: true, containerName }; +} + +/** SQL-идентификатор: допускаются только безопасные символы, иначе quoting невозможен. */ +const SQL_IDENTIFIER_RE = /^[a-z_][a-z0-9_]*$/i; + +/** Кавычит SQL-идентификатор после проверки алфавита. */ +export function quoteSqlIdentifier(name: string): string { + if (!SQL_IDENTIFIER_RE.test(name)) { + throw new Error( + `Имя арендатора "${name}" недопустимо для SQL: разрешены буквы, цифры и "_" (первый символ — буква или "_").` + ); + } + return `"${name}"`; +} + +/** Строит SQL-литерал строки с экранированием одинарных кавычек. */ +export function quoteSqlLiteral(value: string): string { + if (value.includes("\n") || value.includes("\r")) { + throw new Error("Значение секрета не должно содержать переводов строк."); + } + return `'${value.replace(/'/g, "''")}'`; +} + +/** + * SQL подготовки арендатора PostgreSQL: идемпотентное создание роли и базы. + * Пароль подставляется в текст, который передаётся через stdin — не через argv. + */ +export function buildPostgresProvisionSql(tenantName: string, password: string | undefined): string { + const quoted = quoteSqlIdentifier(tenantName); + const passwordLiteral = password !== undefined ? quoteSqlLiteral(password) : null; + const lines: string[] = ["\\set ON_ERROR_STOP on"]; + + if (passwordLiteral !== null) { + // Роль создаётся с паролем; если роль уже существует, пароль не меняется — + // смена пароля существующего арендатора могла бы сломать уже работающие приложения. + lines.push( + `DO $$ BEGIN`, + ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${tenantName}') THEN`, + ` EXECUTE format('CREATE ROLE %I WITH LOGIN PASSWORD %L', '${tenantName}', ${passwordLiteral});`, + ` END IF;`, + `END $$;` + ); + } else { + lines.push( + `DO $$ BEGIN`, + ` IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${tenantName}') THEN`, + ` EXECUTE format('CREATE ROLE %I WITH LOGIN', '${tenantName}');`, + ` END IF;`, + `END $$;` + ); + } + + lines.push( + `SELECT 'CREATE DATABASE ${quoted} OWNER ${quoted}'`, + `WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${tenantName}')\\gexec`, + // Арендатор управляет только своей базой: расширяем права внутри неё. + `GRANT ALL PRIVILEGES ON DATABASE ${quoted} TO ${quoted};` + ); + return lines.join("\n") + "\n"; +} + +/** Результат подготовки арендатора. */ +export interface ProvisionTenantResult { + /** Был ли арендатор создан в этом вызове (false — уже существовал). */ + created: boolean; + /** Префикс ключей (redis) или имя базы (postgres). */ + name: string; + /** Пояснение, если подготовка пропущена. */ + skipped?: string; +} + +/** + * Готовит арендатора в пуле и регистрирует ссылку в реестре. + * + * Идемпотентно: повторный вызов не пересоздаёт базу и не меняет пароль. + * Значение секрета передаётся вызывающим кодом из `.env` в памяти процесса и + * не сохраняется ни в реестре, ни на диск. + */ +export async function provisionSharedTenant( + deps: SharedInfraDeps, + poolKey: string, + profile: SharedPoolProfile, + scenario: string, + ref: TenantRef, + tenant: SharedTenantConfig, + secretValue: string | undefined +): Promise { + const containerName = sharedPoolName(profile, scenario); + + if (profile.kind === "redis") { + // Redis не имеет изоляции по базам с отдельными правами: арендаторы + // разделяют инстанс, а изоляция достигается префиксом ключей. Префикс + // инжектится в приложения через PEGAS_SHARED_KEY_PREFIX. + const state = loadSharedState(deps); + saveSharedState(registerTenant(state, poolKey, ref), deps); + return { created: false, name: tenant.name, skipped: "redis: изоляция префиксом ключей, подготовка не требуется" }; + } + + if (!SQL_IDENTIFIER_RE.test(tenant.name)) { + throw new Error( + `services.${ref.service}: shared.name "${tenant.name}" недопустимо для имени базы PostgreSQL ` + + `(разрешены буквы, цифры и "_", первый символ — буква или "_").` + ); + } + + const sql = buildPostgresProvisionSql(tenant.name, secretValue); + // `-i` без `-t`: SQL уходит в stdin, значение пароля не появляется в argv. + const res = await deps.run( + "docker", + ["exec", "-i", containerName, "psql", "-v", "ON_ERROR_STOP=1", "-U", "postgres", "-q"], + sql + ); + if (!res.success) { + throw new Error( + `Подготовка арендатора "${tenant.name}" в пуле ${containerName} не удалась: ${res.stderr || res.stdout}` + ); + } + + const state = loadSharedState(deps); + const existed = (state.pools[poolKey] ?? []).some((t) => tenantKey(t) === tenantKey(ref)); + saveSharedState(registerTenant(state, poolKey, ref), deps); + return { created: !existed, name: tenant.name }; +} + +/** + * Останавливает пул, если арендаторов не осталось (issue #29, раздел 4). + * + * Данные не удаляются: останавливается только процесс. Следующий деплой любого + * проекта-арендатора запустит инстанс заново на прежнем томе. + */ +export async function stopSharedPoolIfUnused( + deps: SharedInfraDeps, + poolKey: string, + containerName: string +): Promise<{ stopped: boolean; reason: string }> { + const state = loadSharedState(deps); + const remaining = countTenants(state, poolKey); + if (remaining > 0) { + return { stopped: false, reason: `пул удержан: активных арендаторов ${remaining}` }; + } + + const inspect = await deps.run("docker", ["inspect", containerName, "--format", "{{.State.Running}}"]); + if (!inspect.success || inspect.stdout.trim() !== "true") { + return { stopped: false, reason: "пул уже остановлен" }; + } + const stopped = await deps.run("docker", ["stop", containerName]); + if (!stopped.success) { + throw new Error(`Не удалось остановить разделяемый пул ${containerName}: ${stopped.stderr || stopped.stdout}`); + } + return { stopped: true, reason: "пул остановлен: арендаторов не осталось" }; +} + +/** + * Освобождает ссылки проекта и останавливает опустевшие пулы. + * Вызывается из `pegas down` после снятия контейнеров проекта. + */ +export async function releaseProjectSharedPools( + deps: SharedInfraDeps, + project: string, + scenario?: string +): Promise<{ released: number; stoppedPools: string[] }> { + const state = loadSharedState(deps); + const affected: Array<{ poolKey: string; tenants: TenantRef[] }> = []; + for (const [poolKey, tenants] of Object.entries(state.pools)) { + const mine = tenants.filter((t) => t.project === project && (!scenario || t.scenario === scenario)); + if (mine.length > 0) affected.push({ poolKey, tenants: mine }); + } + if (affected.length === 0) return { released: 0, stoppedPools: [] }; + + // Сначала освобождаем арендаторов и фиксируем состояние, затем по одному + // останавливаем опустевшие пулы. stopSharedPoolIfUnused перечитывает + // состояние с диска, поэтому видит уже уменьшенный список арендаторов. + saveSharedState(releaseProjectTenants(state, project, scenario), deps); + + const stoppedPools: string[] = []; + for (const { poolKey } of affected) { + const containerName = `pegas-shared-${poolKey}`; + const result = await stopSharedPoolIfUnused(deps, poolKey, containerName); + if (result.stopped) stoppedPools.push(containerName); + } + + return { released: affected.reduce((sum, a) => sum + a.tenants.length, 0), stoppedPools }; +} diff --git a/src/table.test.ts b/src/table.test.ts index c5153c5..c5dbe72 100644 --- a/src/table.test.ts +++ b/src/table.test.ts @@ -17,6 +17,50 @@ describe("table utilities", () => { expect(stringVisualWidth("\x1b[32m🟢 Up (healthy)\x1b[0m")).toBe(15); }); + it("символы текстового представления занимают одну ячейку (issue #30)", () => { + // ★/✓/✗ — Emoji_Presentation=No: терминал рисует их узкими. + // Прежняя проверка «весь диапазон 0x2600–0x27BF широкий» ломала выравнивание. + expect(stringVisualWidth("★")).toBe(1); + expect(stringVisualWidth("✓")).toBe(1); + expect(stringVisualWidth("✗")).toBe(1); + // Эмодзи-представление в том же диапазоне остаётся широким. + expect(stringVisualWidth("⚪")).toBe(2); + expect(stringVisualWidth("➖")).toBe(2); + expect(stringVisualWidth("✨")).toBe(2); + }); + + it("вариатор U+FE0F переключает текстовый символ на широкое представление", () => { + expect(stringVisualWidth("⚠")).toBe(1); + expect(stringVisualWidth("⚠️")).toBe(2); + // U+FE0E фиксирует текстовое представление даже у эмодзи-символа + expect(stringVisualWidth("🟢\u{FE0E}")).toBe(1); + }); + + it("невидимые символы не занимают ячеек", () => { + expect(stringVisualWidth("a\u{200B}b")).toBe(2); + expect(stringVisualWidth("a\u{FEFF}b")).toBe(2); + expect(stringVisualWidth("\x1b[32m\x1b[0m")).toBe(0); + }); + + it("широкие символы занимают две ячейки: имена проектов не разъезжаются", () => { + // CJK-иероглиф U+6F22 и хангыль U+D55C заданы escapes, чтобы тест не зависел + // от кодировки файла; ширина в терминале у них — 2 ячейки. + expect(stringVisualWidth("\u{6F22}\u{5B57}")).toBe(4); + expect(stringVisualWidth("\u{D55C}\u{AE00}")).toBe(4); + expect(stringVisualWidth("app\u{6F22}")).toBe(5); + }); + + it("renderTable выравнивает строки с узкими и широкими символами", () => { + const output = renderTable({ + headers: ["", "NAME"], + rows: [["★", "app"], ["", "app"]], + }); + // Все линии рамки одной длины — иначе таблица разъезжается в терминале. + const widths = new Set(output.split("\n").map((l) => stringVisualWidth(l))); + expect(widths.size).toBe(1); + expect(output).toContain("★"); + }); + it("padEndVisual pads strings to visual width correctly", () => { expect(padEndVisual("abc", 5)).toBe("abc "); expect(padEndVisual("\x1b[32mabc\x1b[0m", 5)).toBe("\x1b[32mabc\x1b[0m "); diff --git a/src/table.ts b/src/table.ts index 3e3700a..39bee3f 100644 --- a/src/table.ts +++ b/src/table.ts @@ -7,23 +7,113 @@ export function stripAnsi(str: string): string { return str.replace(/\x1b\[[0-9;]*m/g, ""); } +/** + * Кодпоинты 0x2600–0x27BF со свойством Emoji_Presentation (Unicode). + * + * Только они занимают две терминальные ячейки в этом диапазоне. Остальные + * символы там — текстового представления и рисуются в одну ячейку: прежняя + * проверка «весь диапазон 0x2600–0x27BF широкий» ломала выравнивание таблиц на + * символах вроде ★ (U+2605), ✓ (U+2713) и ➖ (U+2796). + */ +const EMOJI_PRESENTATION_2600: ReadonlySet = new Set([ + 0x2614, 0x2615, // ☔ ☕ + 0x2648, 0x2649, 0x264a, 0x264b, 0x264c, 0x264d, 0x264e, 0x264f, // знаки зодиака + 0x2650, 0x2651, 0x2652, 0x2653, + 0x267f, // ♿ + 0x2693, // ⚓ + 0x26a1, // ⚡ + 0x26aa, 0x26ab, // ⚪ ⚫ + 0x26bd, 0x26be, // ⚽ ⚾ + 0x26c4, 0x26c5, // ⛄ ⛅ + 0x26ce, // ⛎ + 0x26d4, // ⛔ + 0x26ea, // ⛪ + 0x26f2, 0x26f3, // ⛲ ⛳ + 0x26f5, // ⛵ + 0x26fa, // ⛺ + 0x26fd, // ⛽ + 0x2705, // ✅ + 0x270a, 0x270b, // ✊ ✋ + 0x2728, // ✨ + 0x274c, 0x274e, // ❌ ❎ + 0x2753, 0x2754, 0x2755, // ❓ ❔ ❕ + 0x2757, // ❗ + 0x2795, 0x2796, 0x2797, // ➕ ➖ ➗ + 0x27b0, // ➰ + 0x27bf, // ➿ +]); + +/** + * Диапазоны East Asian Wide/Fullwidth: CJK-иероглифы, хангыль, полная ширина. + * Без них имена проектов с CJK-символами разъезжали бы в таблице. + */ +const WIDE_RANGES: ReadonlyArray = [ + [0x1100, 0x115f], // Jamo + [0x2e80, 0x303e], // CJK-радикалы, пунктуация + [0x3041, 0x33ff], // кана, CJK-символы + [0x3400, 0x4dbf], // CJK Ext A + [0x4e00, 0x9fff], // CJK Unified + [0xa000, 0xa4cf], // Yi + [0xa960, 0xa97f], // Jamo Extended-A + [0xac00, 0xd7a3], // Hangul + [0xf900, 0xfaff], // CJK Compatibility + [0xfe10, 0xfe19], // вертикальные формы + [0xfe30, 0xfe6f], // CJK-формы совместимости + [0xff00, 0xff60], // полная ширина + [0xffe0, 0xffe6], // полная ширина (знаки) + [0x1f300, 0x1f9ff], // эмодзи-блоки + [0x1fa70, 0x1faff], // эмодзи (расширения) + [0x20000, 0x3fffd], // CJK Ext B+ +]; + +/** Ширина одного кодпоинта в терминальных ячейках. */ +function codePointWidth(cp: number): number { + if (cp === 0) return 0; + // Невидимые: вариант-селекторы, zero-width joiner/space, комбинирующие метки. + if (cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfeff) return 0; + if (cp === 0xfe0e || cp === 0xfe0f) return 0; + if (cp >= 0x0300 && cp <= 0x036f) return 0; + if (cp >= 0x20d0 && cp <= 0x20f0) return 0; + // Управляющие символы не занимают ячеек. + if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; + + if (cp >= 0x2600 && cp <= 0x27bf) { + return EMOJI_PRESENTATION_2600.has(cp) ? 2 : 1; + } + for (const [lo, hi] of WIDE_RANGES) { + if (cp >= lo && cp <= hi) return 2; + } + return 1; +} + /** * Calculates the visible column width in terminal cells. * Accounts for ANSI codes (0 width) and wide characters / emojis (2 width). + * + * Символ с Emoji_Presentation=No рисуется в одну ячейку, если за ним не идёт + * вариатор U+FE0F (VS16) — он переключает представление на эмодзи и делает + * символ широким. U+FE0E (VS15), наоборот, фиксирует текстовое представление. */ export function stringVisualWidth(str: string): number { const clean = stripAnsi(str); + const cps = [...clean].map((ch) => ch.codePointAt(0) || 0); let width = 0; - for (const char of clean) { - const cp = char.codePointAt(0) || 0; - // Emojis (e.g. 🟢, 🔴, 🟡, ⚪) and wide Unicode ranges occupy 2 terminal cells - if ((cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x2600 && cp <= 0x27bf)) { + for (let i = 0; i < cps.length; i++) { + const cp = cps[i]; + const next = cps[i + 1]; + if (next === 0xfe0f) { + // VS16: эмодзи-представление — символ широкий, сам вариатор невидим. width += 2; - } else if (cp >= 0x20 && cp <= 0x7e) { - width += 1; - } else { + i++; + continue; + } + if (next === 0xfe0e) { + // VS15: текстовое представление — символ узкий, вариатор невидим. width += 1; + i++; + continue; } + width += codePointWidth(cp); } return width; } diff --git a/src/types.ts b/src/types.ts index 8903b6c..15fa5e7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,39 @@ export interface ServiceRuntime { driver: ServiceDriver; } +/** + * Область жизненного цикла сервиса в сценарии (issue #29). + * + * - `isolated` (по умолчанию): собственный контейнер проекта — прежнее поведение; + * - `shared`: разделяемый инфраструктурный пул Pegas. Один инстанс на профиль + * образа обслуживает арендаторов (отдельная база/ключи на проект), поэтому + * N проектов занимают память одного процесса, а не N процессов. + * + * Применяется только к docker-сервисам с публичным образом (инфраструктура): + * приложение с `build` не разделяемо — его код принадлежит проекту. + */ +export type ServiceScope = "isolated" | "shared"; + +/** Профиль разделяемого пула: какие инстансы поддерживать. */ +export type SharedPoolKind = "postgres" | "redis"; + +/** + * Параметры аренды в разделяемом пуле (сценарная карта). + * + * Имя базы/пользователя или префикс ключей должно различать проекты внутри + * одного инстанса. Значение берётся из контракта, поэтому аренда идемпотентна + * между деплоями и не требует ручного SQL. + */ +export interface SharedTenantConfig { + /** Имя базы данных и роли (postgres) либо префикс ключей (redis). */ + name: string; + /** + * Имя секрета из `.env` со значением пароля/токена арендатора. Значение + * передаётся через stdin `docker exec` и не сохраняется на диск. + */ + secret?: string; +} + /** Конфигурация шлюза для сценария (замещает v2 devGateway и статический gateway). */ export interface GatewayScenarioConfig { /** Хостовый HTTP-порт шлюза (обычно 80). */ @@ -102,6 +135,16 @@ export interface ServiceContract { * Значения — из .env (dev) / CI-хранилища (prod). Замещает v2 envFile/root secrets. */ secrets?: string[]; + /** + * Область жизненного цикла сервиса по сценариям (issue #29). + * Отсутствие поля — `isolated` (прежнее поведение: собственный контейнер). + */ + scope?: ScenarioMap; + /** + * Параметры аренды в разделяемом пуле по сценариям (issue #29). + * Обязательно для сценариев с `scope: "shared"`; null — сценарий изолирован. + */ + shared?: ScenarioMap; } /** Корневой контракт v3. */ @@ -171,6 +214,12 @@ export interface RouteSnippetOptions { routes?: Record; cors?: boolean | string[] | import("./types.js").CorsConfig; corsOrigins?: string[]; + /** + * Метаданные маршрута (issue #30): записываются комментарием `# pegas:...` + * перед блоком домена. По ним `pegas status` определяет сервис, сценарий и + * драйвер маршрута без эвристики по имени файла. + */ + meta?: import("./route-meta.js").RouteMeta; force?: boolean; dryRun?: boolean; gatewayDir?: string;