feat: add public Docker deployment stack
This commit is contained in:
@@ -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"]
|
||||
@@ -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.`);
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
Executable
+112
@@ -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
|
||||
Executable
+13
@@ -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
|
||||
Reference in New Issue
Block a user