Compare commits
10
Commits
4876f82f85
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cff0af286b | ||
|
|
82b092e1b4 | ||
|
|
a556cab7ea | ||
|
|
87399a35c9 | ||
|
|
5360893b2a | ||
|
|
658e6e9c7f | ||
|
|
69460e9652 | ||
|
|
a71eff8845 | ||
|
|
b930dc8656 | ||
|
|
fe67511203 |
@@ -51,3 +51,6 @@ Thumbs.db
|
|||||||
# Docs tooling
|
# Docs tooling
|
||||||
site/
|
site/
|
||||||
site_test/
|
site_test/
|
||||||
|
|
||||||
|
# ВМ-деплой (не в git)
|
||||||
|
deploy-vm/
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -82,3 +82,47 @@ TL;DR: docs (HTML/CSS/JS, обычно мелкие) стримятся чере
|
|||||||
**Further Considerations**
|
**Further Considerations**
|
||||||
1. Точное значение `STREAM_MAX_BYTES` и итоговый `S3_PUBLIC_BASE_URL` (path-style vs virtual-host style у Ceph RGW) — уточнить на этапе реализации/тестирования, не блокирует план.
|
1. Точное значение `STREAM_MAX_BYTES` и итоговый `S3_PUBLIC_BASE_URL` (path-style vs virtual-host style у Ceph RGW) — уточнить на этапе реализации/тестирования, не блокирует план.
|
||||||
2. Нужен ли листинг версий/лендинг на tf_docs (как у registry `/v1/providers/*/versions`) — сейчас не включено в scope, можно добавить отдельным шагом при необходимости.
|
2. Нужен ли листинг версий/лендинг на tf_docs (как у registry `/v1/providers/*/versions`) — сейчас не включено в scope, можно добавить отдельным шагом при необходимости.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Решение по вариантам реализации (2026-09-02, дополнение)
|
||||||
|
|
||||||
|
Обнаружено, что `tf_docs/server.js` уже закоммичен (`4876f82 feat: add S3-backed documentation streaming service`) и использует `@aws-sdk/client-s3` (`GetObjectCommand` + `HeadBucketCommand`) — стримит весь контент через pod, без redirect. Зависимость `@aws-sdk/client-s3@3.879.0` уже в `package.json`.
|
||||||
|
|
||||||
|
Рассматривались два варианта:
|
||||||
|
|
||||||
|
1. **Redirect-версия** (по плану выше): мелкие HTML/CSS/JS стримить через pod, крупные/нестандартные файлы отдавать `302` на прямой публичный S3 URL; только stdlib Node.
|
||||||
|
2. **AWS SDK-версия** (закоммиченная): стримить весь контент через pod.
|
||||||
|
|
||||||
|
**Принято решение: оставить вариант 2, протестировать; перейти на вариант 1 только при реальных проблемах (таймауты/обрывы на крупных файлах).**
|
||||||
|
|
||||||
|
Обоснование:
|
||||||
|
|
||||||
|
- типовая документация MkDocs — мелкие HTML/CSS/JS, стриминг через pod не создаёт проблем;
|
||||||
|
- вариант 2 позволяет держать бакет `docs/*` приватным (без public policy);
|
||||||
|
- redirect нужен только при появлении крупных вложений (attachments/zip/pdf/видео).
|
||||||
|
|
||||||
|
Критерии перехода на вариант 1:
|
||||||
|
|
||||||
|
- крупный файл (pdf/zip > 2MB) через `/docs/...` обрывается или таймаутит;
|
||||||
|
- деградация при параллельных запросах;
|
||||||
|
- неприемлемая latency двойного hop (браузер → pod → S3).
|
||||||
|
|
||||||
|
Прецедент для варианта 1: инцидент `/v1/proxy` для бинарников провайдера — стриминг ZIP через pod обрывался на ~20KB, заменён на HTTP 302 redirect (см. `tf_registry/HISTORY/incident-2026-09-01-proxy-s3-dns.md`).
|
||||||
|
|
||||||
|
Redirect-версия (вариант 1) сгенерирована DeepSeek Flash, но **не записана** в `server.js`, чтобы не перезаписывать закоммиченный код. При переходе менять только `server.js`.
|
||||||
|
|
||||||
|
**Dockerfile** — до этого отсутствовал, создан и скорректирован под вариант 2:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
COPY server.js ./
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
(установка зависимостей обязательна — иначе `@aws-sdk/client-s3` не найдётся в контейнере).
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# tf_docs: переход на 302-редирект + раздача через ВМ
|
||||||
|
|
||||||
|
Дата: 2026-09-02
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Отдача больших MkDocs-страниц (~133 KB) через под managed-кластера `nodejsk8s`
|
||||||
|
захлёбывалась: `273 B/s` через `tf_docs` (за 80 c скачано 21 KB). Входной шлюз
|
||||||
|
managed-кластера рвёт большие тела (как в drhider: шлюз режет >~64 KB, egress не ограничен).
|
||||||
|
|
||||||
|
## Диагностика (замеры)
|
||||||
|
|
||||||
|
| Путь → S3 | Скорость |
|
||||||
|
|---|---|
|
||||||
|
| `tf_docs` (кластер) | 273 B/s |
|
||||||
|
| локальная машина (Node.js) | 24.5 KiB/s |
|
||||||
|
| **ВМ `5.172.178.213`** | **5.6 MB/s** (133 KB за 0.023 с) |
|
||||||
|
|
||||||
|
Вывод: S3 быстрый, медленный — путь от managed-кластера/внешней сети. ВМ имеет
|
||||||
|
быстрый прямой канал до S3. Паттерн решения взят из drhider («ВМ-буфер», `~/nubes/HowTo/vm-file-upload-service.md`): большие данные — мимо шлюза кластера, через ВМ.
|
||||||
|
|
||||||
|
## Решение (финальная архитектура)
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер → tf_docs (кластер, только 302) → ВМ nginx → статика (зеркало S3)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tf_docs` — тонкий редирект-роутер: `/health` + `302` на ВМ. Без S3, без стримов.
|
||||||
|
- ВМ `5.172.178.213` — nginx раздаёт статику из локального зеркала S3.
|
||||||
|
- URL без версий: `/nubes-test/...` → S3 `docs/nubes-test/nubes/...` (перезапись).
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
### `tf_docs` (репо, запушено `5360893`, версия `0.0.3`)
|
||||||
|
|
||||||
|
- `server.js` — переписан на 302-роутер (сгенерирован DeepSeek Flash):
|
||||||
|
- `GET /health` → `200 ok`
|
||||||
|
- `GET /:stand/...` → `302 Location: ${VM_DOCS_BASE_URL}/:stand/...`
|
||||||
|
- убраны `@aws-sdk/client-s3`, S3-креды, стрим
|
||||||
|
- `package.json` — `0.0.3`, зависимостей нет (чистый `http`).
|
||||||
|
- `test/server.test.js` — 4 теста (health, redirect, root, 405) — PASS.
|
||||||
|
- `.gitignore` — добавлен `deploy-vm/`.
|
||||||
|
|
||||||
|
### `tf_docs/deploy-vm/` (локально, НЕ в git)
|
||||||
|
|
||||||
|
- `nginx-tf-docs.conf` — location `/(nubes-test|nubes-dev|nubes)/` → статика `/var/www/tf-docs/`.
|
||||||
|
- `mirror-s3.sh` — `mc mirror` из S3 `docs/<stand>/nubes/` → `/var/www/tf-docs/<stand>/`.
|
||||||
|
- `README.md` — инструкция деплоя на ВМ.
|
||||||
|
|
||||||
|
### `tf_provider` (репо, запушено `dc469c6`)
|
||||||
|
|
||||||
|
- `scripts/publish-docs.sh` — заливка **без версии** (`docs/<ns>/<name>/`), перезапись `mc mirror --overwrite --remove`.
|
||||||
|
- `TOOLS/scripts/04_build_and_publish_docs.sh` — `site_url: https://<host>/<namespace>/` (без name/version); `docs_dir` относительный (для docker-сборки); хост `tf-docs.nodejsk8s.dev.nubes.ru`.
|
||||||
|
|
||||||
|
## Env tf_docs (jsonEnv в UI)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"VM_DOCS_BASE_URL": "http://5.172.178.213"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
S3-креды `tf_docs` больше не нужны (не ходит в S3). Креды — только на ВМ в `mirror-s3.sh`.
|
||||||
|
|
||||||
|
## Порядок деплоя
|
||||||
|
|
||||||
|
1. **tf_docs**: redeploy в ЛК облака (образ из gitea `5360893`), env `VM_DOCS_BASE_URL`, health `/health`.
|
||||||
|
2. **ВМ**: скопировать `deploy-vm/`, `./mirror-s3.sh nubes-test`, вставить nginx-конфиг, `nginx -t && reload`.
|
||||||
|
3. **Перезалить документацию**: `04` + `publish-docs.sh` → `docs/nubes-test/nubes/` (без версии).
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I https://tf-docs.nodejsk8s.dev.nubes.ru/nubes-test/ # 302 → ВМ
|
||||||
|
curl -I http://5.172.178.213/nubes-test/ # 200 index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## Урок (не повторять)
|
||||||
|
|
||||||
|
- `kube5s.ru` — **legacy**, закрывается. Не использовать ни в коде, ни в доках.
|
||||||
|
- HowTo-документация местами устарела (описывает legacy-домен как рабочий) — сверять с актуальными источниками.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 2026-09-02 — Анализ Соннета по tf_docs + критическое мнение
|
||||||
|
|
||||||
|
## Вопрос (что отдавали Соннету)
|
||||||
|
|
||||||
|
Анализ финальной архитектуры отдачи MkDocs-документации через «вечный» бесплатный
|
||||||
|
домен `tf-docs.nodejsk8s.dev.nubes.ru`: браузер → под nodejsk8s (reverse-proxy) →
|
||||||
|
ВМ 5.172.178.213 (nginx/статика). Просили: правильна ли схема, можно ли ускорить
|
||||||
|
международный маршрут без платных доменов, скрытые риски.
|
||||||
|
|
||||||
|
## Ответ Соннета (суть)
|
||||||
|
|
||||||
|
1. Архитектура верна, но есть **конфигурационный дрейф**: в репо
|
||||||
|
`deploy-vm/nginx-ip-5.172.178.213.conf` всё ещё `location / { proxy_pass
|
||||||
|
http://127.0.0.1:8888/; }` (на остановленный llmui), хотя на ВМ уже `return 301`.
|
||||||
|
2. Обрывы ~16–36 КБ — похожи на инцидент tf_registry (там виноват локальный
|
||||||
|
прокси), но у нас Германия без прокси тоже режет + ВМ напрямую режет → маршрут
|
||||||
|
в РФ фильтруется на границе. Предложил **включить gzip**, чтобы «сжать страницу
|
||||||
|
ниже порога обрыва».
|
||||||
|
3. Риски: SPOF на ВМ, ВМ не выделенный хост, `location /` открыт по IP, timeout
|
||||||
|
30с (idle), TLS только на ingress.
|
||||||
|
|
||||||
|
## Моё критическое мнение
|
||||||
|
|
||||||
|
**Главное: рекомендация gzip — ошибочна.** Порог обрыва — СЕТЕВОЙ (~16.7 КБ), а не
|
||||||
|
«по объёму контента». Доказательство из замеров этой же сессии:
|
||||||
|
|
||||||
|
- файл `.test_20k.bin` (ровно 20000 байт) обрывается на 16775 байт;
|
||||||
|
- файлы 50/100/200 КБ — тоже ровно на 16773–16775;
|
||||||
|
- favicon 1017 байт — проходит полностью.
|
||||||
|
|
||||||
|
То есть обрыв наступает на ~16.7 КБ **независимо от content-length**. Сжать HTML
|
||||||
|
133 КБ gzip до <16.7 КБ нереально (текст жмётся до ~25–40 КБ) — ответ всё равно
|
||||||
|
упрётся в сетевой порог. gzip меняет размер контента, но не лимит маршрута. Гипотеза
|
||||||
|
«сжать ниже порога» не сработает. Вывод: для страниц 133–200 КБ gzip проблему не
|
||||||
|
решает.
|
||||||
|
|
||||||
|
**Остальные пункты** — валидные, но в основном уже известные/принятые trade-off'ы:
|
||||||
|
- дрейф 8888 в репо — подтверждён, реальный риск при повторном применении файла из
|
||||||
|
git (файл стоит синхронизировать с ВМ);
|
||||||
|
- SPOF на ВМ, timeout 30с, TLS-только-ingress — верно, но «ВМ-буфер» и есть
|
||||||
|
осознанное решение (весь код на ВМ);
|
||||||
|
- «location / открыт по IP» — натянуто: ВМ и так публична по IP, новой дыры нет.
|
||||||
|
|
||||||
|
**Итог:** анализ полезен, но ключевая рекомендация (gzip) технически неверна;
|
||||||
|
реально ценное — пункт про дрейф 8888 (нужно синхронизировать файл в репо).
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
|
||||||
|
- Дрейф `deploy-vm/nginx-ip-5.172.178.213.conf` (8888) — ИСПРАВЛЕН: файл синхронизирован
|
||||||
|
с ВМ (`return 301 /nubes-test/` + `absolute_redirect off`). Файл gitignored, в git не
|
||||||
|
входит — риск «применения из git» не подтвердился, это локальная справочная копия.
|
||||||
|
- gzip — НЕ делать (не решает).
|
||||||
|
- Range-проброс в `server.js` — НЕ делать (для MkDocs-статики не критично).
|
||||||
Generated
+2
-1312
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "tf-docs",
|
"name": "tf-docs",
|
||||||
"version": "0.0.0",
|
"version": "0.0.5",
|
||||||
"description": "Terraform docs service (S3 static /docs) — initial skeleton",
|
"description": "Terraform docs reverse-proxy (stream from VM static server)",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
@@ -10,8 +10,5 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@aws-sdk/client-s3": "3.879.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
Ты — инженер. Проанализируй задачу ниже и дай КРАТКИЙ, ясный ответ БЕЗ ВОДЫ: что правильно, что неправильно, что стоит поменять. Не пересказывай вводные.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ЦЕЛЬ
|
||||||
|
|
||||||
|
Документация Terraform-провайдера Nubes (статический MkDocs-сайт, страницы 133–200 КБ, поисковый индекс ~1.3 МБ) должна открываться по БЕСПЛАТНОМУ «вечному» домену:
|
||||||
|
`https://tf-docs.nodejsk8s.dev.nubes.ru`
|
||||||
|
Без IP в адресной строке, без платных доменов (продление домена стоит денег — не вариант). Домен даёт облако Nubes бесплатно как managed-сервис.
|
||||||
|
|
||||||
|
## АРХИТЕКТУРА (текущая)
|
||||||
|
|
||||||
|
```
|
||||||
|
Браузер
|
||||||
|
→ tf-docs.nodejsk8s.dev.nubes.ru (managed-кластер nodejsk8s, ingress-шлюз 185.247.187.151)
|
||||||
|
→ под tf_docs (Node.js, reverse-proxy, 0.0.5)
|
||||||
|
→ ВМ 5.172.178.213 (nginx, статика из /var/www/tf-docs/)
|
||||||
|
→ MkDocs HTML
|
||||||
|
```
|
||||||
|
|
||||||
|
`tf_docs/server.js`: reverse-proxy. `/health` → 200. `/` → 301 на `/nubes-test/`. `/:stand/...` → `${VM_DOCS_BASE_URL}/:stand/...`. Форвардит content-type/content-length/cache-control/content-encoding/location. На ВМ nginx: `location /nubes-test/` отдаёт статику, `location /` = `return 301 /nubes-test/` + `absolute_redirect off`.
|
||||||
|
|
||||||
|
## ЧТО ПРОБОВАЛИ И ЧТО НЕ ПОЛУЧАЛОСЬ (хронология)
|
||||||
|
|
||||||
|
1. **Стриминг docs из S3 через под** → упиралось в ~273 Б/с (шлюз резал). Отказались.
|
||||||
|
2. **Redirect 302 с пода на ВМ** → браузер уходил на `http://5.172.178.213/...` — IP в адресе, домен терялся. Не подходит.
|
||||||
|
3. **Reverse-proxy (текущее)** → URL остаётся на домене. Принято как финальное.
|
||||||
|
4. **502 на корне `/`** → баг на ВМ: `location /` проксировал на `127.0.0.1:8888` (Docker `llmui`, остановлен по соображениям безопасности). Починено: `return 301 /nubes-test/`.
|
||||||
|
5. **301 терял заголовок `Location`** → в прокси не был в списке форвардящихся заголовков. Добавили `location`. Плюс `absolute_redirect off` на ВМ, чтобы Location был относительным (`/nubes-test/`), а не `http://5.172.178.213/...`.
|
||||||
|
|
||||||
|
## ГЛАВНАЯ ПРОБЛЕМА — СКОРОСТЬ/ОБРЫВЫ (диагностировано)
|
||||||
|
|
||||||
|
Симптом: большие ответы «замирали» ровно на ~16–20 КБ.
|
||||||
|
|
||||||
|
Замеры:
|
||||||
|
- ВМ напрямую из РФ: **5 МБ/с** (133 КБ за 0.026 с).
|
||||||
|
- Домен из РФ без прокси: **133 КБ за ~5 с, 1.3 МБ за ~5 с** — работает полностью.
|
||||||
|
- Домен через локальный v2ray-прокси (`HTTPS_PROXY=172.17.192.1:10808`): **стоп на 16788 байт**.
|
||||||
|
- Сервер в Германии (Vultr 95.179.252.111), чистый, без прокси:
|
||||||
|
- шлюз `185.247.187.151`: стоп на 16788 байт;
|
||||||
|
- ВМ `5.172.178.213` напрямую: стоп на 35912 байт (~1 КБ/с);
|
||||||
|
- контроль (google.com, не-РФ): 83 КБ за 0.48 с — канал Германии хороший.
|
||||||
|
- iotdash22.nodejsk8s.dev.nubes.ru (другой шлюз 91.214.116.216) отдаёт 17.7 КБ полностью.
|
||||||
|
|
||||||
|
Вывод, к которому пришли: режется **международный маршрут → российские IP**, а не наша архитектура. Из РФ напрямую всё быстро.
|
||||||
|
|
||||||
|
## ТЕКУЩЕЕ СОСТОЯНИЕ
|
||||||
|
|
||||||
|
Всё работает из РФ напрямую (браузер открывает страницы быстро, это подтверждено пользователем). Проверено в браузере: `/` → 301 → `/nubes-test/` (домен сохраняется), getting-started-страница (197 КБ) грузится полностью. ВМ отдаёт поду все байты (access log nginx: 133525, 200000 и т.д.).
|
||||||
|
|
||||||
|
## ЧТО ОТ ТЕБЯ НУЖНО
|
||||||
|
|
||||||
|
1. Правильна ли текущая архитектура для поставленной цели (бесплатный вечный домен + быстрая отдача из РФ)? Есть ли фундаментальные изъяны?
|
||||||
|
2. Есть ли способ ускорить/сделать надёжнее отдачу для трафика, который идёт через зарубежный маршрут (прокси/Германия), не вводя платных доменов? (или это физически нерешаемо на этом хостинге)
|
||||||
|
3. Скрытые риски/узкие места текущего решения (single point of failure на ВМ, TLS, кэш, безопасность `location /` и т.д.).
|
||||||
|
|
||||||
|
Ответ: кратко, по пунктам, без воды.
|
||||||
@@ -1,136 +1,124 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const https = require('https');
|
||||||
const { S3Client, GetObjectCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = Number(process.env.PORT) || 3000;
|
||||||
const VERSION = process.env.APP_VERSION || '0.0.0';
|
const PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS) || 30000;
|
||||||
const SERVICE = 'tf-docs';
|
const VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL;
|
||||||
const S3_BUCKET = process.env.S3_BUCKET || '';
|
|
||||||
const DOCS_S3_PREFIX = (process.env.DOCS_S3_PREFIX || 'docs').replace(/^\/+|\/+$/g, '');
|
|
||||||
const S3_ENDPOINT = process.env.S3_ENDPOINT || '';
|
|
||||||
const CACHE_CONTROL = process.env.CACHE_CONTROL || 'public, max-age=60';
|
|
||||||
|
|
||||||
const s3 = new S3Client({
|
if (!VM_DOCS_BASE_URL) {
|
||||||
region: process.env.S3_REGION || 'us-east-1',
|
throw new Error('VM_DOCS_BASE_URL environment variable is required');
|
||||||
endpoint: S3_ENDPOINT ? `https://${S3_ENDPOINT.replace(/^https?:\/\//, '')}` : undefined,
|
|
||||||
forcePathStyle: true,
|
|
||||||
credentials: process.env.S3_ACCESS_KEY && process.env.S3_SECRET_KEY
|
|
||||||
? { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY }
|
|
||||||
: undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
function getDocsCandidates(urlPath) {
|
|
||||||
const relativePath = decodeURIComponent(urlPath.replace(/^\/docs\/?/, ''));
|
|
||||||
const normalized = path.posix.normalize(`/${relativePath}`).replace(/^\/+|\/+$/g, '');
|
|
||||||
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const base = `${DOCS_S3_PREFIX}/${normalized}`;
|
|
||||||
const candidates = [];
|
|
||||||
if (urlPath.endsWith('/')) {
|
|
||||||
candidates.push(`${base}/index.html`);
|
|
||||||
} else {
|
|
||||||
candidates.push(base);
|
|
||||||
if (!path.posix.extname(normalized)) candidates.push(`${base}/index.html`);
|
|
||||||
candidates.push(`${DOCS_S3_PREFIX}/${normalized.split('/').slice(0, 3).join('/')}/index.html`);
|
|
||||||
}
|
|
||||||
return [...new Set(candidates)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentTypeFor(key) {
|
const UPSTREAM = new URL(VM_DOCS_BASE_URL);
|
||||||
const types = {
|
if (UPSTREAM.protocol !== 'http:' && UPSTREAM.protocol !== 'https:') {
|
||||||
'.html': 'text/html; charset=utf-8',
|
throw new Error('VM_DOCS_BASE_URL must start with http:// or https://');
|
||||||
'.css': 'text/css; charset=utf-8',
|
}
|
||||||
'.js': 'application/javascript; charset=utf-8',
|
|
||||||
'.json': 'application/json; charset=utf-8',
|
const UPSTREAM_MODULE = UPSTREAM.protocol === 'https:' ? https : http;
|
||||||
'.svg': 'image/svg+xml',
|
const UPSTREAM_BASE_PATH = UPSTREAM.pathname.replace(/\/+$/, '');
|
||||||
'.png': 'image/png',
|
const UPSTREAM_PORT = UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80);
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
const STAND_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
'.webp': 'image/webp'
|
const FORWARD_HEADERS = ['content-type', 'content-length', 'cache-control', 'content-encoding', 'location'];
|
||||||
|
|
||||||
|
function respond(res, status, body) {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyRequest(req, res, targetPath) {
|
||||||
|
const options = {
|
||||||
|
protocol: UPSTREAM.protocol,
|
||||||
|
hostname: UPSTREAM.hostname,
|
||||||
|
port: UPSTREAM_PORT,
|
||||||
|
method: req.method,
|
||||||
|
path: targetPath,
|
||||||
|
timeout: PROXY_TIMEOUT_MS,
|
||||||
};
|
};
|
||||||
return types[path.posix.extname(key).toLowerCase()] || 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function docsHandler(req, res) {
|
const upstreamReq = UPSTREAM_MODULE.request(options, (upstreamRes) => {
|
||||||
const candidates = getDocsCandidates(req.url.split('?')[0]);
|
const headers = {};
|
||||||
if (!candidates || !S3_BUCKET) {
|
for (const name of FORWARD_HEADERS) {
|
||||||
res.statusCode = 400;
|
if (upstreamRes.headers[name] !== undefined) {
|
||||||
res.end('Invalid documentation path or S3_BUCKET is not configured');
|
headers[name] = upstreamRes.headers[name];
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
res.writeHead(upstreamRes.statusCode || 502, headers);
|
||||||
|
upstreamRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
for (const key of candidates) {
|
upstreamReq.on('timeout', () => {
|
||||||
try {
|
upstreamReq.destroy(new Error('Upstream timeout'));
|
||||||
const result = await s3.send(new GetObjectCommand({ Bucket: S3_BUCKET, Key: key }));
|
});
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader('Content-Type', result.ContentType || contentTypeFor(key));
|
|
||||||
res.setHeader('Cache-Control', CACHE_CONTROL);
|
|
||||||
if (result.ContentLength !== undefined) res.setHeader('Content-Length', result.ContentLength);
|
|
||||||
result.Body.pipe(res);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name !== 'NoSuchKey' && error.$metadata?.httpStatusCode !== 404) {
|
|
||||||
res.statusCode = 502;
|
|
||||||
res.end('S3 request failed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.statusCode = 404;
|
upstreamReq.on('error', () => {
|
||||||
res.end('Documentation file not found');
|
if (res.headersSent) {
|
||||||
}
|
res.destroy();
|
||||||
|
} else {
|
||||||
|
respond(res, 502, 'Bad Gateway');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function readyz(_req, res) {
|
req.on('aborted', () => upstreamReq.destroy());
|
||||||
if (!S3_BUCKET) {
|
|
||||||
res.statusCode = 200;
|
upstreamReq.end();
|
||||||
res.end(JSON.stringify({ status: 'ready', s3: 'not configured' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await s3.send(new HeadBucketCommand({ Bucket: S3_BUCKET }));
|
|
||||||
res.statusCode = 200;
|
|
||||||
res.end(JSON.stringify({ status: 'ready', s3: 'ok' }));
|
|
||||||
} catch (_error) {
|
|
||||||
res.statusCode = 503;
|
|
||||||
res.end(JSON.stringify({ status: 'not ready', s3: 'unavailable' }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if (req.url === '/healthz') {
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
res.statusCode = 200;
|
respond(res, 405, 'Method Not Allowed');
|
||||||
res.setHeader('Content-Type', 'application/json');
|
|
||||||
res.end(JSON.stringify({ status: 'ok', version: VERSION }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.url === '/readyz') {
|
|
||||||
readyz(req, res);
|
const rawUrl = req.url || '/';
|
||||||
|
const qIndex = rawUrl.indexOf('?');
|
||||||
|
const rawPath = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
|
||||||
|
const rawQuery = qIndex === -1 ? '' : rawUrl.slice(qIndex);
|
||||||
|
|
||||||
|
if (rawPath === '/health') {
|
||||||
|
respond(res, 200, 'ok');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.url.startsWith('/docs/')) {
|
|
||||||
docsHandler(req, res);
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = decodeURIComponent(rawPath);
|
||||||
|
} catch (_) {
|
||||||
|
respond(res, 400, 'Bad Request');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.statusCode = 200;
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
if (decoded.split('/').some((seg) => seg === '..')) {
|
||||||
res.end(`<!DOCTYPE html>
|
respond(res, 400, 'Bad Request');
|
||||||
<html lang="ru">
|
return;
|
||||||
<head>
|
}
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>${SERVICE}</title>
|
let targetPath;
|
||||||
</head>
|
if (decoded === '/') {
|
||||||
<body>
|
targetPath = UPSTREAM_BASE_PATH + '/' + rawQuery;
|
||||||
<h1>${SERVICE}</h1>
|
} else {
|
||||||
<p>version: ${VERSION}</p>
|
const stand = decoded.split('/').filter(Boolean)[0];
|
||||||
</body>
|
if (!stand || !STAND_RE.test(stand)) {
|
||||||
</html>`);
|
respond(res, 400, 'Bad Request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
targetPath = UPSTREAM_BASE_PATH + rawPath + rawQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyRequest(req, res, targetPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
if (require.main === module) {
|
||||||
console.log(`[${SERVICE}] listening on :${PORT} (version ${VERSION})`);
|
server.listen(PORT, () => {
|
||||||
});
|
console.log(`[docs-proxy] listening on port ${PORT}, upstream: ${VM_DOCS_BASE_URL}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { server };
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const http = require('node:http');
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
let server;
|
||||||
|
|
||||||
|
const upstream = http.createServer((req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength('<html>ok</html>'),
|
||||||
|
});
|
||||||
|
res.end('<html>ok</html>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.before(async () => {
|
||||||
|
await new Promise((r) => upstream.listen(0, '127.0.0.1', r));
|
||||||
|
process.env.VM_DOCS_BASE_URL = `http://127.0.0.1:${upstream.address().port}`;
|
||||||
|
({ server } = require('../server.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(async () => {
|
||||||
|
await new Promise((r) => upstream.close(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function withServer(fn) {
|
||||||
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||||
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
try {
|
||||||
|
await fn(base);
|
||||||
|
} finally {
|
||||||
|
await new Promise((r) => server.close(r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET /health -> 200 ok', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const res = await fetch(`${base}/health`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(await res.text(), 'ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /nubes-test/ -> proxies to upstream', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const res = await fetch(`${base}/nubes-test/`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(await res.text(), '<html>ok</html>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET / -> proxies to upstream root', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const res = await fetch(`${base}/`);
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
assert.strictEqual(await res.text(), '<html>ok</html>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST -> 405', async () => {
|
||||||
|
await withServer(async (base) => {
|
||||||
|
const res = await fetch(`${base}/health`, { method: 'POST' });
|
||||||
|
assert.strictEqual(res.status, 405);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user