feat(runtime): 정적 프런트엔드와 격리 빌더를 구성한다
Caddy가 불변 아티팩트를 직접 제공하고 runtime과 builder의 자원·비밀 경계를 분리한다. Gateway active release ref를 재기동에 복원하고 production/development/smoke 모델 검증을 확장한다.
This commit is contained in:
@@ -25,3 +25,26 @@ test('Caddy caches only content-hashed Vite frontend assets as immutable', async
|
||||
/header @immutableFrontendAssets >Cache-Control "public, max-age=31536000, immutable"/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Caddy serves published frontend artifacts directly and keeps a transition fallback', async () => {
|
||||
const caddyfile = await fs.readFile(new URL('../caddy/Caddyfile', import.meta.url), 'utf8');
|
||||
for (const [profile, port] of Object.entries({
|
||||
gateway: 15000,
|
||||
che: 15002,
|
||||
kwe: 15004,
|
||||
pwe: 15006,
|
||||
twe: 15008,
|
||||
nya: 15010,
|
||||
pya: 15012,
|
||||
hwe: 15014,
|
||||
})) {
|
||||
assert.match(caddyfile, new RegExp(`/srv/frontend-artifacts/${profile}/current`));
|
||||
assert.match(caddyfile, new RegExp(`reverse_proxy runtime:${port}`));
|
||||
assert.match(
|
||||
caddyfile,
|
||||
new RegExp(`route \\{[\\s\\S]*?@${profile}Asset path /assets/\\*[\\s\\S]*?handle @${profile}Asset`),
|
||||
);
|
||||
}
|
||||
assert.match(caddyfile, /try_files \{path\} \/index\.html/);
|
||||
assert.match(caddyfile, /header @frontendRequests Cache-Control "no-cache"/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const script = path.resolve('runtime/checkout-active-release.sh');
|
||||
const roots = [];
|
||||
const git = (cwd, ...args) =>
|
||||
execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'Sammo Test',
|
||||
GIT_AUTHOR_EMAIL: 'sammo-test@example.invalid',
|
||||
GIT_COMMITTER_NAME: 'Sammo Test',
|
||||
GIT_COMMITTER_EMAIL: 'sammo-test@example.invalid',
|
||||
},
|
||||
}).trim();
|
||||
|
||||
const fixture = () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sammo-active-release-'));
|
||||
roots.push(root);
|
||||
git(root, 'init', '-b', 'main');
|
||||
fs.writeFileSync(path.join(root, 'version.txt'), 'one\n');
|
||||
git(root, 'add', 'version.txt');
|
||||
git(root, 'commit', '-m', 'first');
|
||||
const first = git(root, 'rev-parse', 'HEAD');
|
||||
fs.writeFileSync(path.join(root, 'version.txt'), 'two\n');
|
||||
git(root, 'commit', '-am', 'second');
|
||||
const second = git(root, 'rev-parse', 'HEAD');
|
||||
git(root, 'update-ref', 'refs/sammo/active-gateway', first);
|
||||
return { root, first, second };
|
||||
};
|
||||
|
||||
test.afterEach(() => {
|
||||
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('checks out the persistent active release instead of the previous bootstrap HEAD', () => {
|
||||
const { root, first, second } = fixture();
|
||||
assert.equal(git(root, 'rev-parse', 'HEAD'), second);
|
||||
execFileSync('/bin/sh', [script, root, 'refs/sammo/active-gateway']);
|
||||
assert.equal(git(root, 'rev-parse', 'HEAD'), first);
|
||||
});
|
||||
|
||||
test('rejects refs outside the managed release namespace', () => {
|
||||
const { root } = fixture();
|
||||
const result = spawnSync('/bin/sh', [script, root, 'refs/heads/main'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 64);
|
||||
assert.match(result.stderr, /refs\/sammo/u);
|
||||
});
|
||||
|
||||
test('refuses to hide tracked bootstrap checkout changes', () => {
|
||||
const { root, second } = fixture();
|
||||
fs.writeFileSync(path.join(root, 'version.txt'), 'dirty\n');
|
||||
const result = spawnSync('/bin/sh', [script, root, 'refs/sammo/active-gateway'], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 65);
|
||||
assert.equal(git(root, 'rev-parse', 'HEAD'), second);
|
||||
});
|
||||
@@ -5,12 +5,13 @@ import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const ecosystem = require('../runtime/ecosystem.config.cjs');
|
||||
|
||||
test('defines exactly four unique, bounded Gateway processes', () => {
|
||||
test('defines only backend Gateway processes and no Vite preview process', () => {
|
||||
assert.deepEqual(
|
||||
ecosystem.apps.map((app) => app.name),
|
||||
['sammo:gateway-api', 'sammo:gateway-frontend', 'sammo:gateway-orchestrator', 'sammo:release-controller'],
|
||||
['sammo:gateway-api', 'sammo:gateway-orchestrator', 'sammo:release-controller'],
|
||||
);
|
||||
assert.equal(new Set(ecosystem.apps.map((app) => app.name)).size, 4);
|
||||
assert.equal(new Set(ecosystem.apps.map((app) => app.name)).size, 3);
|
||||
assert.equal(ecosystem.apps.some((app) => String(app.args ?? '').includes('preview')), false);
|
||||
for (const app of ecosystem.apps) {
|
||||
assert.equal(app.autorestart, true);
|
||||
assert.equal(app.max_restarts, 5);
|
||||
|
||||
@@ -20,6 +20,9 @@ const safeRuntime = {
|
||||
IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret',
|
||||
VITE_IMAGE_PUBLIC_URL: 'https://sam-image.hided.net',
|
||||
VITE_GATEWAY_USER_ICON_BASE_URL: 'https://sam-image.hided.net/icons',
|
||||
FRONTEND_SERVE_MODE: 'static',
|
||||
RELEASE_BUILDER_URL: 'http://builder:15100',
|
||||
GATEWAY_ACTIVE_RELEASE_GIT_REF: 'refs/sammo/active-gateway',
|
||||
},
|
||||
volumes: [
|
||||
{
|
||||
@@ -28,6 +31,12 @@ const safeRuntime = {
|
||||
target: '/run/secrets/image_upload_core2026_secret',
|
||||
read_only: true,
|
||||
},
|
||||
{
|
||||
type: 'volume',
|
||||
source: 'frontend-artifacts',
|
||||
target: '/srv/frontend-artifacts',
|
||||
read_only: false,
|
||||
},
|
||||
{
|
||||
type: 'bind',
|
||||
source: '/secrets/image_sync_core2026_secret',
|
||||
@@ -42,12 +51,46 @@ const safeRuntime = {
|
||||
pids_limit: 256,
|
||||
};
|
||||
|
||||
const safeBuilder = {
|
||||
environment: {
|
||||
NODE_OPTIONS: '--max-old-space-size=3072',
|
||||
RAYON_NUM_THREADS: '2',
|
||||
},
|
||||
restart: 'unless-stopped',
|
||||
mem_limit: String(4 * 1024 * 1024 * 1024),
|
||||
memswap_limit: String(4 * 1024 * 1024 * 1024),
|
||||
cpus: 4,
|
||||
pids_limit: 256,
|
||||
};
|
||||
|
||||
const safeCaddy = {
|
||||
restart: 'unless-stopped',
|
||||
volumes: [
|
||||
{
|
||||
type: 'volume',
|
||||
source: 'frontend-artifacts',
|
||||
target: '/srv/frontend-artifacts',
|
||||
read_only: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const safeServices = () => ({
|
||||
runtime: structuredClone(safeRuntime),
|
||||
builder: structuredClone(safeBuilder),
|
||||
caddy: structuredClone(safeCaddy),
|
||||
});
|
||||
|
||||
test('accepts a bounded production runtime', () => {
|
||||
assert.deepEqual(validateComposeModel({ services: { runtime: safeRuntime } }, 'production'), []);
|
||||
assert.deepEqual(validateComposeModel({ services: safeServices() }, 'production'), []);
|
||||
});
|
||||
|
||||
test('accepts a literal, non-restarting development model', () => {
|
||||
const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'no' }]));
|
||||
const services = safeServices();
|
||||
services.postgres = { restart: 'no' };
|
||||
services.redis = { restart: 'no' };
|
||||
services.builder.restart = 'no';
|
||||
services.caddy.restart = 'no';
|
||||
services.runtime = {
|
||||
...safeRuntime,
|
||||
restart: 'no',
|
||||
@@ -61,13 +104,19 @@ test('accepts a literal, non-restarting development model', () => {
|
||||
});
|
||||
|
||||
test('accepts a bounded, non-restarting production smoke model', () => {
|
||||
const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'no' }]));
|
||||
const services = safeServices();
|
||||
services.postgres = { restart: 'no' };
|
||||
services.redis = { restart: 'no' };
|
||||
services.builder.restart = 'no';
|
||||
services.caddy.restart = 'no';
|
||||
services.runtime = { ...safeRuntime, restart: 'no' };
|
||||
assert.deepEqual(validateComposeModel({ services }, 'smoke'), []);
|
||||
});
|
||||
|
||||
test('rejects an unbounded or production-mode development runtime', () => {
|
||||
const services = Object.fromEntries(['postgres', 'redis', 'caddy'].map((name) => [name, { restart: 'unless-stopped' }]));
|
||||
const services = safeServices();
|
||||
services.postgres = { restart: 'unless-stopped' };
|
||||
services.redis = { restart: 'unless-stopped' };
|
||||
services.runtime = {
|
||||
environment: { CORE_SOURCE_MODE: 'bind', RUNTIME_MODE: 'production' },
|
||||
restart: 'unless-stopped',
|
||||
@@ -92,7 +141,9 @@ test('rejects missing or excessive build memory and parallelism limits', () => {
|
||||
RAYON_NUM_THREADS: '8',
|
||||
},
|
||||
};
|
||||
const errors = validateComposeModel({ services: { runtime } }, 'production');
|
||||
const services = safeServices();
|
||||
services.runtime = runtime;
|
||||
const errors = validateComposeModel({ services }, 'production');
|
||||
assert.ok(errors.some((error) => error.includes('Node heap limit')));
|
||||
assert.ok(errors.some((error) => error.includes('turn daemon Node heap limit')));
|
||||
assert.ok(errors.some((error) => error.includes('Rayon thread count')));
|
||||
|
||||
Reference in New Issue
Block a user