feat: add S3-backed documentation streaming service
This commit is contained in:
@@ -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, можно добавить отдельным шагом при необходимости.
|
||||
Generated
+1325
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,124 @@
|
||||
'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', 'text/html; charset=utf-8');
|
||||
res.end(`<!DOCTYPE html>
|
||||
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>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -19,8 +128,7 @@ const server = http.createServer((req, res) => {
|
||||
<h1>${SERVICE}</h1>
|
||||
<p>version: ${VERSION}</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
</html>`);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
|
||||
Reference in New Issue
Block a user