feat: add S3-backed documentation streaming service

This commit is contained in:
“Naeel”
2026-09-02 11:42:30 +03:00
parent e6719164f5
commit 4876f82f85
4 changed files with 1526 additions and 5 deletions
@@ -0,0 +1,84 @@
# Plan: tf_docs Node.js docs-display service + S3 upload redesign (redirect for big files)
TL;DR: docs (HTML/CSS/JS, обычно мелкие) стримятся через Node.js-сервис как раньше;
любой файл, который либо не входит в whitelist расширений сайта, либо превышает
порог размера (большие attachments/zip/pdf/видео), отдаётся через `302 Location`
на прямой публичный S3 URL — байты через под в кластере не идут. Бакет `docs/*`
уже задуман как public-read (mc policy public в DOCS_PIPELINE/publish-docs.sh),
поэтому сервису не нужны S3-креды — только HTTP HEAD/GET к публичному bucket URL.
**Ответы пользователя (зафиксировано):**
- Архитектура: redirect для больших файлов, HTML/CSS стримить через сервис (не полный redirect-only).
- Бакет: публичный read, простой прямой URL (без presigned).
- Публичный URL после перехода: "как лучше — так и делай" → решено: **site_url и текущая
дружественная схема `https://tf-registry.containerk8s.services.ngcloud.ru/docs/<ns>/<name>/<version>/`
НЕ меняются**, т.к. основная масса запросов (HTML/CSS/JS) всё равно идёт через сервис.
- Объём работ по S3-заливке: восстановить `scripts/publish-docs.sh`, поправить `04_build_and_publish_docs.sh`
при необходимости, гарантировать public policy — пользователь выбрал все три, но т.к. site_url не меняется,
правка `04_build_and_publish_docs.sh`/mkdocs.yml не требуется (кроме проверки, что public policy сохраняется).
**Фаза 1 — tf_provider: восстановление заливки в S3**
1. Восстановить `tf_provider/scripts/publish-docs.sh` на основе рабочей копии
`tf_provider/DOCS_PIPELINE/publish-docs.sh` (тот же bucket `terraform-registry`,
тот же префикс `docs/<namespace>/<name>/<version>/`, тот же `mc policy set public`).
Это чинит разрыв, зафиксированный в `TMP/tf_provider_full_docs_pipeline_2026-09-02.md` (§7)
и `tf_provider/HISTORY/2026-09-02_full_docs_pipeline_analysis.md`.
2. Убедиться, что `.github/workflows/publish-docs.yml` и `TOOLS/scripts/04_build_and_publish_docs.sh`
вызывают именно это имя файла (без изменений интерфейса).
3. Конвенция для больших вложений (картинки/примеры/zip/pdf в `docs/curated/**`, `docs/30_registry/**`):
класть их статическим файлом прямо в дерево `docs_dir` (mkdocs копирует не-markdown файлы
в `site/` автоматически) — отдельного шага заливки не требуется, только структурная договорённость
(например подпапка `attachments/` рядом со страницей).
4. `mkdocs.yml` / `site_url` — БЕЗ ИЗМЕНЕНИЙ (см. решение выше).
**Фаза 2 — tf_docs: сам Node.js-сервис** (*зависит от подтверждения публичной policy бакета, иначе параллельно с Фазой 1*)
5. Заменить заглушку в `tf_docs/server.js` (сейчас просто "hello" HTML) на роутер:
- `GET /docs/:namespace/:name/:version/*rest` (+ вариант без rest → index).
- `GET /healthz` → 200 для k8s-проб.
6. Конфигурация через env (добавить в `tf_docs/package.json`/README):
- `S3_PUBLIC_BASE_URL` (напр. `https://s3.msk-1.ngcloud.ru/terraform-registry`)
- `PORT`, `CACHE_TTL_SECONDS` (по умолчанию 300)
- `STREAM_MAX_BYTES` (по умолчанию 2_000_000)
- `STREAM_EXTENSIONS` (по умолчанию: html,css,js,mjs,json,svg,png,jpg,jpeg,gif,ico,webp,woff,woff2,ttf,map,txt,xml)
7. Маппинг пути → S3-ключ и fallback-цепочка (перенос правил из
`tf_registry/HISTORY/docs-service-plan.md`): точный ключ → `<ключ>/index.html` → корневой `index.html`.
Проверка существования — `HEAD` к `${S3_PUBLIC_BASE_URL}/<key>` (без SDK и кредов, т.к. бакет публичный).
8. Ветвление по каждому разрешённому ключу:
- расширение из `STREAM_EXTENSIONS` И размер (Content-Length из HEAD) ≤ `STREAM_MAX_BYTES`
`GET`, стримить тело клиенту, `Content-Type` по расширению (fallback `text/html; charset=utf-8`
для html/пусто, иначе `application/octet-stream`), `Cache-Control: public, max-age=<TTL>`,
опциональный in-memory TTL-кэш по ключу+ETag.
- иначе (большой файл или не-сайтовое расширение) → `302 Found`, `Location: ${S3_PUBLIC_BASE_URL}/<key>`,
тело не читается вообще.
- если ни один из fallback-ключей не резолвится (HEAD 404 везде) → 404.
9. `tf_docs/Dockerfile` (node:18-alpine, `npm ci --omit=dev`, `CMD node server.js`) — свериться со стилем
`tf_registry/Dockerfile` для консистентности образов.
10. Ingress: путь `/docs/*` на хосте `tf-registry.containerk8s.services.ngcloud.ru` → сервис tf_docs;
`/.well-known/*`, `/v1/providers/*`, `/v1/proxy` остаются на tf_registry (без изменений).
**Верификация**
1. `node -c tf_docs/server.js` после каждой правки.
2. Локально: поднять сервис с `S3_PUBLIC_BASE_URL` на реальный бакет, проверить curl:
- маленькая html-страница → `200` с телом;
- файл больше `STREAM_MAX_BYTES` или не из whitelist → `302` + корректный `Location`;
- несуществующий путь → `404`.
3. По аналогии с `tf_registry/tests/smoke.sh` — добавить smoke-скрипт для tf_docs.
4. Перед релизом — проверить `mc policy get` (или эквивалент) на префиксе `docs/*`, что policy = public.
**Relevant files**
- `tf_docs/server.js`, `tf_docs/package.json`, `tf_docs/README.md` — реализация сервиса.
- `tf_docs/Dockerfile` — новый.
- `tf_provider/scripts/publish-docs.sh` — восстановить (источник: `tf_provider/DOCS_PIPELINE/publish-docs.sh`).
- `tf_provider/TOOLS/scripts/04_build_and_publish_docs.sh`, `.github/workflows/publish-docs.yml` — проверить вызов без изменений.
- `tf_registry/HISTORY/docs-service-plan.md` — обновить (стриминг+кэш → гибрид stream/redirect по порогу).
- `tf_registry/server/proxy.go` — референс уже применённого паттерна redirect-на-presigned-S3.
**Decisions**
- Бакет `docs/*` публичный (mc policy public) → сервису не нужны S3 SDK/креды, только обычные HTTP HEAD/GET.
- site_url и структура ссылок mkdocs не меняются — экономит объём правок, т.к. большинство запросов остаётся мелким HTML/CSS/JS.
- Классификация "большой файл" — гибрид: whitelist расширений + порог размера (не только расширение), чтобы не полагаться на одно правило.
- Конвенция вложений — положить статик-файлы прямо в `docs_dir` (без нового шага пайплайна).
**Further Considerations**
1. Точное значение `STREAM_MAX_BYTES` и итоговый `S3_PUBLIC_BASE_URL` (path-style vs virtual-host style у Ceph RGW) — уточнить на этапе реализации/тестирования, не блокирует план.
2. Нужен ли листинг версий/лендинг на tf_docs (как у registry `/v1/providers/*/versions`) — сейчас не включено в scope, можно добавить отдельным шагом при необходимости.
+1325
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -5,9 +5,13 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"check": "node -c server.js"
"check": "node -c server.js",
"test": "node --test"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@aws-sdk/client-s3": "3.879.0"
}
}
+110 -2
View File
@@ -1,12 +1,121 @@
'use strict';
const http = require('http');
const path = require('path');
const { S3Client, GetObjectCommand, HeadBucketCommand } = require('@aws-sdk/client-s3');
const PORT = process.env.PORT || 3000;
const VERSION = process.env.APP_VERSION || '0.0.0';
const SERVICE = 'tf-docs';
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({
region: process.env.S3_REGION || 'us-east-1',
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 types = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp'
};
return types[path.posix.extname(key).toLowerCase()] || 'application/octet-stream';
}
async function docsHandler(req, res) {
const candidates = getDocsCandidates(req.url.split('?')[0]);
if (!candidates || !S3_BUCKET) {
res.statusCode = 400;
res.end('Invalid documentation path or S3_BUCKET is not configured');
return;
}
for (const key of candidates) {
try {
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;
res.end('Documentation file not found');
}
async function readyz(_req, res) {
if (!S3_BUCKET) {
res.statusCode = 200;
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) => {
if (req.url === '/healthz') {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ status: 'ok', version: VERSION }));
return;
}
if (req.url === '/readyz') {
readyz(req, res);
return;
}
if (req.url.startsWith('/docs/')) {
docsHandler(req, res);
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<!DOCTYPE html>
@@ -19,8 +128,7 @@ const server = http.createServer((req, res) => {
<h1>${SERVICE}</h1>
<p>version: ${VERSION}</p>
</body>
</html>
`);
</html>`);
});
server.listen(PORT, () => {