feat: redirect router (302 to VM) instead of S3 stream + bump 0.0.3
This commit is contained in:
@@ -51,3 +51,6 @@ Thumbs.db
|
||||
# Docs tooling
|
||||
site/
|
||||
site_test/
|
||||
|
||||
# ВМ-деплой (не в git)
|
||||
deploy-vm/
|
||||
|
||||
Generated
+2
-1312
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tf-docs",
|
||||
"version": "0.0.2",
|
||||
"description": "Terraform docs service (S3 static /docs) — initial skeleton",
|
||||
"version": "0.0.3",
|
||||
"description": "Terraform docs redirect router (302 to VM static server)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
@@ -10,8 +10,5 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.879.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +1,79 @@
|
||||
'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.2';
|
||||
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 PORT = Number(process.env.PORT) || 3000;
|
||||
const VM_DOCS_BASE_URL = (process.env.VM_DOCS_BASE_URL || '').replace(/\/+$/, '');
|
||||
|
||||
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
|
||||
});
|
||||
const STAND_RE = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function getDocsCandidates(urlPath) {
|
||||
const relativePath = decodeURIComponent(urlPath.replace(/^\/+/, ''));
|
||||
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 send(res, status, body, headers = {}) {
|
||||
const data = Buffer.from(String(body));
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Length': data.length,
|
||||
...headers,
|
||||
});
|
||||
res.end(data);
|
||||
}
|
||||
|
||||
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 health(_req, res) {
|
||||
if (!S3_BUCKET) {
|
||||
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('ok');
|
||||
} catch (error) {
|
||||
res.statusCode = 503;
|
||||
res.end('s3 unreachable: ' + (error && error.message ? error.message : error));
|
||||
}
|
||||
function redirect(res, location) {
|
||||
send(res, 302, '', { Location: location });
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === '/health') {
|
||||
health(req, res);
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
send(res, 405, 'Method Not Allowed');
|
||||
return;
|
||||
}
|
||||
docsHandler(req, res);
|
||||
|
||||
let pathname;
|
||||
try {
|
||||
pathname = decodeURIComponent(req.url.split('?')[0]);
|
||||
} catch (err) {
|
||||
send(res, 400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/health') {
|
||||
send(res, 200, 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
if (!VM_DOCS_BASE_URL) {
|
||||
send(res, 500, 'VM_DOCS_BASE_URL is not configured');
|
||||
return;
|
||||
}
|
||||
redirect(res, VM_DOCS_BASE_URL + '/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VM_DOCS_BASE_URL) {
|
||||
send(res, 500, 'VM_DOCS_BASE_URL is not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').filter((s) => s !== '');
|
||||
if (segments.some((s) => s === '..')) {
|
||||
send(res, 400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
const stand = segments[0];
|
||||
if (!stand || !STAND_RE.test(stand)) {
|
||||
send(res, 404, 'Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
redirect(res, VM_DOCS_BASE_URL + pathname);
|
||||
});
|
||||
|
||||
if (require.main === module) {
|
||||
server.listen(PORT, () => {
|
||||
console.log(`[${SERVICE}] listening on :${PORT} (version ${VERSION})`);
|
||||
console.log(`redirect router listening on :${PORT}`);
|
||||
console.log(`VM_DOCS_BASE_URL=${VM_DOCS_BASE_URL || '(not set)'}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDocsCandidates,
|
||||
contentTypeFor,
|
||||
health,
|
||||
docsHandler,
|
||||
server,
|
||||
s3,
|
||||
};
|
||||
module.exports = { server, send, redirect };
|
||||
|
||||
+15
-82
@@ -1,12 +1,11 @@
|
||||
'use strict';
|
||||
|
||||
process.env.S3_BUCKET = process.env.S3_BUCKET || 'terraform-registry';
|
||||
process.env.VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL || 'http://vm.example';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { Readable } = require('node:stream');
|
||||
|
||||
const { getDocsCandidates, contentTypeFor, server, s3 } = require('../server.js');
|
||||
const { server } = require('../server.js');
|
||||
|
||||
async function withServer(fn) {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
@@ -18,100 +17,34 @@ async function withServer(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 } });
|
||||
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');
|
||||
});
|
||||
s3.send = originalSend;
|
||||
});
|
||||
|
||||
test('GET /health -> 503 when S3 unreachable', async () => {
|
||||
const originalSend = s3.send;
|
||||
s3.send = async () => { throw new Error('boom'); };
|
||||
test('GET /nubes-test/akhq/ -> 302 to VM', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/health`);
|
||||
assert.strictEqual(res.status, 503);
|
||||
assert.match(await res.text(), /s3 unreachable/);
|
||||
const res = await fetch(`${base}/nubes-test/akhq/`, { redirect: 'manual' });
|
||||
assert.strictEqual(res.status, 302);
|
||||
assert.strictEqual(res.headers.get('location'), 'http://vm.example/nubes-test/akhq/');
|
||||
});
|
||||
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 } };
|
||||
};
|
||||
test('GET / -> 302 to VM root', async () => {
|
||||
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>');
|
||||
const res = await fetch(`${base}/`, { redirect: 'manual' });
|
||||
assert.strictEqual(res.status, 302);
|
||||
assert.strictEqual(res.headers.get('location'), 'http://vm.example/');
|
||||
});
|
||||
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 } };
|
||||
};
|
||||
test('POST -> 405', async () => {
|
||||
await withServer(async (base) => {
|
||||
const res = await fetch(`${base}/nubes/nubes/1.0.0/missing`);
|
||||
assert.strictEqual(res.status, 404);
|
||||
const res = await fetch(`${base}/health`, { method: 'POST' });
|
||||
assert.strictEqual(res.status, 405);
|
||||
});
|
||||
s3.send = originalSend;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user