Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
658e6e9c7f | ||
|
|
69460e9652 | ||
|
|
a71eff8845 | ||
|
|
b930dc8656 | ||
|
|
fe67511203 |
+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**
|
||||
1. Точное значение `STREAM_MAX_BYTES` и итоговый `S3_PUBLIC_BASE_URL` (path-style vs virtual-host style у Ceph RGW) — уточнить на этапе реализации/тестирования, не блокирует план.
|
||||
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` не найдётся в контейнере).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tf-docs",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.2",
|
||||
"description": "Terraform docs service (S3 static /docs) — initial skeleton",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 VERSION = process.env.APP_VERSION || '0.0.2';
|
||||
const SERVICE = 'tf-docs';
|
||||
const S3_BUCKET = process.env.S3_BUCKET || '';
|
||||
const DOCS_S3_PREFIX = (process.env.DOCS_S3_PREFIX || 'docs').replace(/^\/+|\/+$/g, '');
|
||||
@@ -22,7 +22,7 @@ const s3 = new S3Client({
|
||||
});
|
||||
|
||||
function getDocsCandidates(urlPath) {
|
||||
const relativePath = decodeURIComponent(urlPath.replace(/^\/docs\/?/, ''));
|
||||
const relativePath = decodeURIComponent(urlPath.replace(/^\/+/, ''));
|
||||
const normalized = path.posix.normalize(`/${relativePath}`).replace(/^\/+|\/+$/g, '');
|
||||
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||
return null;
|
||||
@@ -85,52 +85,41 @@ async function docsHandler(req, res) {
|
||||
res.end('Documentation file not found');
|
||||
}
|
||||
|
||||
async function readyz(_req, res) {
|
||||
async function health(_req, res) {
|
||||
if (!S3_BUCKET) {
|
||||
res.statusCode = 200;
|
||||
res.end(JSON.stringify({ status: 'ready', s3: 'not configured' }));
|
||||
res.statusCode = 503;
|
||||
res.end('s3 unreachable: S3_BUCKET 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.end('ok');
|
||||
} catch (error) {
|
||||
res.statusCode = 503;
|
||||
res.end(JSON.stringify({ status: 'not ready', s3: 'unavailable' }));
|
||||
res.end('s3 unreachable: ' + (error && error.message ? error.message : error));
|
||||
}
|
||||
}
|
||||
|
||||
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 }));
|
||||
if (req.url === '/health') {
|
||||
health(req, res);
|
||||
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">
|
||||
<title>${SERVICE}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${SERVICE}</h1>
|
||||
<p>version: ${VERSION}</p>
|
||||
</body>
|
||||
</html>`);
|
||||
docsHandler(req, res);
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
if (require.main === module) {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[${SERVICE}] listening on :${PORT} (version ${VERSION})`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDocsCandidates,
|
||||
contentTypeFor,
|
||||
health,
|
||||
docsHandler,
|
||||
server,
|
||||
s3,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
'use strict';
|
||||
|
||||
process.env.S3_BUCKET = process.env.S3_BUCKET || 'terraform-registry';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { Readable } = require('node:stream');
|
||||
|
||||
const { getDocsCandidates, contentTypeFor, server, s3 } = require('../server.js');
|
||||
|
||||
async function withServer(fn) {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
try {
|
||||
await fn(base);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
// --- getDocsCandidates (URL -> S3 keys) ---
|
||||
|
||||
test('candidates: trailing slash -> index.html', () => {
|
||||
assert.deepStrictEqual(
|
||||
getDocsCandidates('/nubes/nubes/1.0.0/'),
|
||||
['docs/nubes/nubes/1.0.0/index.html']
|
||||
);
|
||||
});
|
||||
|
||||
test('candidates: file with extension -> exact + root index fallback', () => {
|
||||
assert.deepStrictEqual(
|
||||
getDocsCandidates('/nubes/nubes/1.0.0/page.html'),
|
||||
['docs/nubes/nubes/1.0.0/page.html', 'docs/nubes/nubes/1.0.0/index.html']
|
||||
);
|
||||
});
|
||||
|
||||
test('candidates: directory-style path -> exact + index.html + root index', () => {
|
||||
assert.deepStrictEqual(
|
||||
getDocsCandidates('/nubes/nubes/1.0.0/guides/getting-started'),
|
||||
[
|
||||
'docs/nubes/nubes/1.0.0/guides/getting-started',
|
||||
'docs/nubes/nubes/1.0.0/guides/getting-started/index.html',
|
||||
'docs/nubes/nubes/1.0.0/index.html',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
// --- contentTypeFor ---
|
||||
|
||||
test('contentTypeFor: known and unknown extensions', () => {
|
||||
assert.strictEqual(contentTypeFor('index.html'), 'text/html; charset=utf-8');
|
||||
assert.strictEqual(contentTypeFor('style.css'), 'text/css; charset=utf-8');
|
||||
assert.strictEqual(contentTypeFor('app.js'), 'application/javascript; charset=utf-8');
|
||||
assert.strictEqual(contentTypeFor('logo.png'), 'image/png');
|
||||
assert.strictEqual(contentTypeFor('data.json'), 'application/json; charset=utf-8');
|
||||
assert.strictEqual(contentTypeFor('file.unknown'), 'application/octet-stream');
|
||||
});
|
||||
|
||||
// --- /health ---
|
||||
|
||||
test('GET /health -> 200 ok when S3 available', async () => {
|
||||
const originalSend = s3.send;
|
||||
s3.send = async () => ({ $metadata: { httpStatusCode: 200 } });
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/health`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(await res.text(), 'ok');
|
||||
});
|
||||
s3.send = originalSend;
|
||||
});
|
||||
|
||||
test('GET /health -> 503 when S3 unreachable', async () => {
|
||||
const originalSend = s3.send;
|
||||
s3.send = async () => { throw new Error('boom'); };
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/health`);
|
||||
assert.strictEqual(res.status, 503);
|
||||
assert.match(await res.text(), /s3 unreachable/);
|
||||
});
|
||||
s3.send = originalSend;
|
||||
});
|
||||
|
||||
// --- /docs ---
|
||||
|
||||
test('GET /docs/... -> 200 streams body with content-type', async () => {
|
||||
const originalSend = s3.send;
|
||||
s3.send = async (cmd) => {
|
||||
if (cmd.input && cmd.input.Key) {
|
||||
return { Body: Readable.from(['<html>ok</html>']), ContentType: 'text/html' };
|
||||
}
|
||||
return { $metadata: { httpStatusCode: 200 } };
|
||||
};
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/nubes/nubes/1.0.0/`);
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.match(res.headers.get('content-type') || '', /text\/html/);
|
||||
assert.strictEqual(await res.text(), '<html>ok</html>');
|
||||
});
|
||||
s3.send = originalSend;
|
||||
});
|
||||
|
||||
test('GET /docs/... -> 404 when no key found', async () => {
|
||||
const originalSend = s3.send;
|
||||
s3.send = async (cmd) => {
|
||||
if (cmd.input && cmd.input.Key) {
|
||||
const err = new Error('Not Found');
|
||||
err.name = 'NoSuchKey';
|
||||
throw err;
|
||||
}
|
||||
return { $metadata: { httpStatusCode: 200 } };
|
||||
};
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/nubes/nubes/1.0.0/missing`);
|
||||
assert.strictEqual(res.status, 404);
|
||||
});
|
||||
s3.send = originalSend;
|
||||
});
|
||||
Reference in New Issue
Block a user