feat: add public Docker deployment stack

This commit is contained in:
2026-08-04 14:45:31 +00:00
commit 09d849de07
14 changed files with 628 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
FROM node:24.18.0-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git openssh-client procps tini \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable \
&& corepack prepare pnpm@11.17.0 --activate
COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint
COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh
COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs /opt/sammo/
WORKDIR /srv/core/repository
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"]
+69
View File
@@ -0,0 +1,69 @@
const endpoint = 'http://127.0.0.1:15001/gateway/api/trpc';
const post = async (procedure, input, sessionToken) => {
const response = await fetch(`${endpoint}/${procedure}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(sessionToken ? { 'x-session-token': sessionToken } : {}),
},
body: JSON.stringify(input),
});
const body = await response.json();
if (!response.ok) {
const code = body?.error?.data?.code;
const message = body?.error?.message ?? JSON.stringify(body);
const error = new Error(`${procedure} failed (${response.status}, ${code ?? 'unknown'}): ${message}`);
error.code = code;
throw error;
}
return body?.result?.data;
};
const token = process.env.GATEWAY_BOOTSTRAP_TOKEN;
const username = process.env.INITIAL_ADMIN_USERNAME;
const password = process.env.INITIAL_ADMIN_PASSWORD;
if (!token || !username || !password) {
console.log('Initial admin bootstrap is disabled because its environment values are incomplete.');
process.exit(0);
}
let sessionToken;
try {
const result = await post('auth.bootstrapLocal', {
token,
username,
password,
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME || 'Administrator',
});
sessionToken = result?.sessionToken;
if (!sessionToken) throw new Error('bootstrap response did not include a session token');
console.log('Created the initial superuser without printing credentials.');
} catch (error) {
if (error?.code === 'CONFLICT') {
console.log('Initial superuser already exists; bootstrap was left unchanged.');
process.exit(0);
}
throw error;
}
const profilePorts = new Map([
['gateway', 15001],
['che', 15003],
['kwe', 15005],
['pwe', 15007],
['twe', 15009],
['nya', 15011],
['pya', 15013],
['hwe', 15015],
]);
const profiles = (process.env.BOOTSTRAP_PROFILES || 'gateway,che,kwe,pwe,twe,nya,pya,hwe')
.split(',')
.map((value) => value.trim())
.filter((value) => value && value !== 'gateway');
for (const profile of profiles) {
const apiPort = profilePorts.get(profile);
if (!apiPort) throw new Error(`No reserved API port is defined for profile: ${profile}`);
await post('admin.profiles.upsert', { profile, scenario: 'default', apiPort, status: 'STOPPED' }, sessionToken);
}
console.log(`Registered ${profiles.length} stopped profiles for branch-selectable Admin deployments.`);
+37
View File
@@ -0,0 +1,37 @@
const path = require('node:path');
const root = process.env.GATEWAY_WORKSPACE_ROOT || '/srv/core/repository';
const common = { env: { ...process.env }, autorestart: true, kill_timeout: 15000 };
module.exports = {
apps: [
{
...common,
name: 'sammo:gateway-api',
cwd: path.join(root, 'app/gateway-api'),
script: path.join(root, 'app/gateway-api/dist/index.js'),
env: { ...process.env, GATEWAY_ROLE: 'api' },
},
{
...common,
name: 'sammo:gateway-frontend',
cwd: path.join(root, 'app/gateway-frontend'),
script: path.join(root, 'app/gateway-frontend/node_modules/vite/bin/vite.js'),
args: 'preview --host 0.0.0.0 --port 15000',
},
{
...common,
name: 'sammo:gateway-orchestrator',
cwd: path.join(root, 'app/gateway-api'),
script: path.join(root, 'app/gateway-api/dist/index.js'),
env: { ...process.env, GATEWAY_ROLE: 'orchestrator' },
},
{
...common,
name: 'sammo:release-controller',
cwd: path.join(root, 'app/release-controller'),
script: path.join(root, 'app/release-controller/dist/index.js'),
args: 'daemon',
},
],
};
+112
View File
@@ -0,0 +1,112 @@
#!/bin/sh
set -eu
# The container is always non-interactive. This also makes pnpm replace a
# modules directory created with a different store without waiting for a TTY.
export CI="${CI:-true}"
require_env() {
eval "value=\${$1:-}"
if [ -z "$value" ]; then
echo "$1 is required" >&2
exit 64
fi
}
for key in POSTGRES_PASSWORD REDIS_PASSWORD GAME_TOKEN_SECRET KAKAO_REST_KEY KAKAO_REDIRECT_URI; do
require_env "$key"
done
case "${CORE_SOURCE_MODE:-clone}" in
clone)
require_env CORE_REPOSITORY_URL
core_root=/srv/core/repository
if [ ! -d "$core_root/.git" ]; then
mkdir -p /srv/core
git clone --no-single-branch "$CORE_REPOSITORY_URL" "$core_root"
git -C "$core_root" checkout "${CORE_BOOTSTRAP_REF:-main}"
fi
;;
bind)
core_root=${CORE_BIND_ROOT:-/workspace/core2026}
if [ ! -e "$core_root/.git" ] || [ ! -f "$core_root/pnpm-lock.yaml" ]; then
echo "CORE_BIND_ROOT must be a Core2026 Git checkout" >&2
exit 66
fi
;;
*)
echo 'CORE_SOURCE_MODE must be clone or bind' >&2
exit 64
;;
esac
git config --global --add safe.directory "$core_root"
export GATEWAY_WORKSPACE_ROOT="$core_root"
export RELEASE_CONTROLLER_WORKSPACE_ROOT="$core_root"
export DATABASE_URL="$(node -e 'const u=new URL("postgresql://localhost");u.username=process.env.POSTGRES_USER||"sammo";u.password=process.env.POSTGRES_PASSWORD;u.hostname=process.env.POSTGRES_HOST||"postgres";u.port=process.env.POSTGRES_PORT||"5432";u.pathname="/"+(process.env.POSTGRES_DB||"sammo");u.searchParams.set("schema","public");process.stdout.write(u.href)')"
export GATEWAY_DATABASE_URL="$DATABASE_URL"
export REDIS_URL="$(node -e 'const u=new URL("redis://localhost/0");u.password=process.env.REDIS_PASSWORD;u.hostname=process.env.REDIS_HOST||"redis";u.port=process.env.REDIS_PORT||"6379";process.stdout.write(u.href)')"
cd "$core_root"
pnpm install --frozen-lockfile
if [ "${RUNTIME_MODE:-production}" = development ]; then
pnpm --filter @sammo-ts/infra prisma:generate
echo 'Development container is ready. Start the desired pnpm dev processes with docker compose exec runtime.'
if [ "$#" -gt 0 ]; then
exec "$@"
fi
exec sleep infinity
fi
pnpm --filter @sammo-ts/common build
pnpm --filter @sammo-ts/infra prisma:generate
pnpm --filter @sammo-ts/infra build
pnpm --filter @sammo-ts/logic build
pnpm --filter @sammo-ts/game-engine build
pnpm --filter @sammo-ts/gateway-api build
pnpm --filter @sammo-ts/gateway-frontend build
pnpm --filter @sammo-ts/release-controller build
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
GATEWAY_ROLE=api node app/gateway-api/dist/index.js &
bootstrap_pid=$!
cleanup_bootstrap() {
kill "$bootstrap_pid" 2>/dev/null || true
wait "$bootstrap_pid" 2>/dev/null || true
}
trap cleanup_bootstrap EXIT INT TERM
attempt=0
until curl --fail --silent http://127.0.0.1:15001/healthz >/dev/null; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 120 ]; then
echo 'Gateway API did not become ready for bootstrap' >&2
exit 1
fi
sleep 1
done
node /opt/sammo/bootstrap.mjs
cleanup_bootstrap
trap - EXIT INT TERM
pm2_bin="$core_root/app/gateway-api/node_modules/.bin/pm2"
"$pm2_bin" start /opt/sammo/ecosystem.config.cjs
pm2_pid=$(cat "$PM2_HOME/pm2.pid")
tail -n 0 -F "$PM2_HOME/pm2.log" "$PM2_HOME"/logs/*.log &
logs_pid=$!
shutdown_pm2() {
kill "$logs_pid" 2>/dev/null || true
"$pm2_bin" kill >/dev/null 2>&1 || true
wait "$logs_pid" 2>/dev/null || true
}
trap 'shutdown_pm2; exit 0' INT TERM
while kill -0 "$pm2_pid" 2>/dev/null; do
sleep 5
done
shutdown_pm2
echo 'PM2 daemon exited unexpectedly' >&2
exit 1
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
if [ -z "${REDIS_PASSWORD:-}" ]; then
echo 'REDIS_PASSWORD is required' >&2
exit 64
fi
umask 077
install -d -o redis -g redis -m 700 /run/sammo
printf 'user default on >%s ~* &* +@all\n' "$REDIS_PASSWORD" >/run/sammo/users.acl
chown redis:redis /run/sammo/users.acl
exec /usr/local/bin/docker-entrypoint.sh redis-server --appendonly yes --aclfile /run/sammo/users.acl