Yuki Takei 1 месяц назад
Родитель
Сommit
1d4f79c315

+ 3 - 3
.kiro/specs/official-docker-image/spec.json

@@ -3,7 +3,7 @@
   "created_at": "2026-02-20T00:00:00.000Z",
   "created_at": "2026-02-20T00:00:00.000Z",
   "updated_at": "2026-02-20T00:00:00.000Z",
   "updated_at": "2026-02-20T00:00:00.000Z",
   "language": "ja",
   "language": "ja",
-  "phase": "tasks-generated",
+  "phase": "implementing",
   "approvals": {
   "approvals": {
     "requirements": {
     "requirements": {
       "generated": true,
       "generated": true,
@@ -15,8 +15,8 @@
     },
     },
     "tasks": {
     "tasks": {
       "generated": true,
       "generated": true,
-      "approved": false
+      "approved": true
     }
     }
   },
   },
-  "ready_for_implementation": false
+  "ready_for_implementation": true
 }
 }

+ 20 - 20
.kiro/specs/official-docker-image/tasks.md

@@ -10,21 +10,21 @@
 
 
 ## Phase 1: DHI + TypeScript entrypoint(現行ビルドパターン維持)
 ## Phase 1: DHI + TypeScript entrypoint(現行ビルドパターン維持)
 
 
-- [ ] 1. (P) ビルドコンテキストフィルタの強化
+- [x] 1. (P) ビルドコンテキストフィルタの強化
   - 現行の除外ルールに `.git`、`.env*`(production 以外)、テストファイル、IDE 設定ファイル等を追加する
   - 現行の除外ルールに `.git`、`.env*`(production 以外)、テストファイル、IDE 設定ファイル等を追加する
   - セキュリティ上の機密ファイル(シークレット、認証情報)がコンテキストに含まれないことを確認する
   - セキュリティ上の機密ファイル(シークレット、認証情報)がコンテキストに含まれないことを確認する
   - 現行の除外ルール(`node_modules`、`.next`、`.turbo`、`apps/slackbot-proxy` 等)は維持する
   - 現行の除外ルール(`node_modules`、`.next`、`.turbo`、`apps/slackbot-proxy` 等)は維持する
   - _Requirements: 4.3_
   - _Requirements: 4.3_
 
 
-- [ ] 2. TypeScript entrypoint のディレクトリ初期化と権限管理
-- [ ] 2.1 (P) entrypoint スケルトンと再帰 chown ヘルパーの作成
+- [x] 2. TypeScript entrypoint のディレクトリ初期化と権限管理
+- [x] 2.1 (P) entrypoint スケルトンと再帰 chown ヘルパーの作成
   - Node.js 24 の type stripping で直接実行可能な TypeScript ファイルを新規作成する(enum 不使用、erasable syntax のみ)
   - Node.js 24 の type stripping で直接実行可能な TypeScript ファイルを新規作成する(enum 不使用、erasable syntax のみ)
   - メインの実行フローを `main()` 関数として構造化し、エラーハンドリングのトップレベル try-catch を設ける
   - メインの実行フローを `main()` 関数として構造化し、エラーハンドリングのトップレベル try-catch を設ける
   - ディレクトリ内のファイル・サブディレクトリを再帰的に所有者変更するヘルパー関数を実装する
   - ディレクトリ内のファイル・サブディレクトリを再帰的に所有者変更するヘルパー関数を実装する
   - ヘルパー関数のユニットテストを作成する(ネストされたディレクトリ構造での再帰動作を検証)
   - ヘルパー関数のユニットテストを作成する(ネストされたディレクトリ構造での再帰動作を検証)
   - _Requirements: 6.8_
   - _Requirements: 6.8_
 
 
-- [ ] 2.2 ディレクトリ初期化処理の実装
+- [x] 2.2 ディレクトリ初期化処理の実装
   - `/data/uploads` の作成、`./public/uploads` へのシンボリックリンク作成、再帰的な所有者変更を実装する
   - `/data/uploads` の作成、`./public/uploads` へのシンボリックリンク作成、再帰的な所有者変更を実装する
   - `/tmp/page-bulk-export` の作成、再帰的な所有者変更、パーミッション 700 の設定を実装する
   - `/tmp/page-bulk-export` の作成、再帰的な所有者変更、パーミッション 700 の設定を実装する
   - 冪等性を確保する(`recursive: true` による mkdir、既存シンボリックリンクの重複作成防止)
   - 冪等性を確保する(`recursive: true` による mkdir、既存シンボリックリンクの重複作成防止)
@@ -32,14 +32,14 @@
   - 失敗時(ボリュームマウント未設定等)にプロセス終了(exit code 1)することを検証する
   - 失敗時(ボリュームマウント未設定等)にプロセス終了(exit code 1)することを検証する
   - _Requirements: 6.3, 6.4_
   - _Requirements: 6.3, 6.4_
 
 
-- [ ] 2.3 権限ドロップの実装
+- [x] 2.3 権限ドロップの実装
   - root から node ユーザー(UID 1000, GID 1000)への降格処理を実装する
   - root から node ユーザー(UID 1000, GID 1000)への降格処理を実装する
   - supplementary groups の初期化を行い、setgid → setuid の順序を厳守する(逆順だと setgid が失敗する)
   - supplementary groups の初期化を行い、setgid → setuid の順序を厳守する(逆順だと setgid が失敗する)
   - 権限ドロップ失敗時にエラーメッセージを出力してプロセスを終了する
   - 権限ドロップ失敗時にエラーメッセージを出力してプロセスを終了する
   - _Requirements: 4.1, 6.2_
   - _Requirements: 4.1, 6.2_
 
 
-- [ ] 3. ヒープサイズ算出とノードフラグ組み立て
-- [ ] 3.1 (P) cgroup メモリリミット検出の実装
+- [x] 3. ヒープサイズ算出とノードフラグ組み立て
+- [x] 3.1 (P) cgroup メモリリミット検出の実装
   - cgroup v2 ファイルの読み取りと数値パースを実装する(`"max"` 文字列は unlimited として扱う)
   - cgroup v2 ファイルの読み取りと数値パースを実装する(`"max"` 文字列は unlimited として扱う)
   - cgroup v1 ファイルへのフォールバックを実装する(64GB 超は unlimited として扱う)
   - cgroup v1 ファイルへのフォールバックを実装する(64GB 超は unlimited として扱う)
   - メモリリミットの 60% をヒープサイズ(MB 単位)として算出する
   - メモリリミットの 60% をヒープサイズ(MB 単位)として算出する
@@ -47,13 +47,13 @@
   - 各パターン(v2 正常検出、v2 unlimited、v1 フォールバック、v1 unlimited、検出不可)のユニットテストを作成する
   - 各パターン(v2 正常検出、v2 unlimited、v1 フォールバック、v1 unlimited、検出不可)のユニットテストを作成する
   - _Requirements: 2.2, 2.3_
   - _Requirements: 2.2, 2.3_
 
 
-- [ ] 3.2 (P) 環境変数によるヒープサイズ指定の実装
+- [x] 3.2 (P) 環境変数によるヒープサイズ指定の実装
   - `GROWI_HEAP_SIZE` 環境変数のパースとバリデーションを実装する(正の整数、MB 単位)
   - `GROWI_HEAP_SIZE` 環境変数のパースとバリデーションを実装する(正の整数、MB 単位)
   - 不正値(NaN、負数、空文字列)の場合は警告ログを出力してフラグなしにフォールバックする
   - 不正値(NaN、負数、空文字列)の場合は警告ログを出力してフラグなしにフォールバックする
   - 環境変数指定が cgroup 自動算出より優先されることをテストで確認する
   - 環境変数指定が cgroup 自動算出より優先されることをテストで確認する
   - _Requirements: 2.1_
   - _Requirements: 2.1_
 
 
-- [ ] 3.3 ノードフラグの組み立てとログ出力の実装
+- [x] 3.3 ノードフラグの組み立てとログ出力の実装
   - 3 段フォールバック(環境変数 → cgroup 算出 → V8 デフォルト)の統合ロジックを実装する
   - 3 段フォールバック(環境変数 → cgroup 算出 → V8 デフォルト)の統合ロジックを実装する
   - `--expose_gc` フラグを常時付与する
   - `--expose_gc` フラグを常時付与する
   - `GROWI_OPTIMIZE_MEMORY=true` で `--optimize-for-size`、`GROWI_LITE_MODE=true` で `--lite-mode` を追加する
   - `GROWI_OPTIMIZE_MEMORY=true` で `--optimize-for-size`、`GROWI_LITE_MODE=true` で `--lite-mode` を追加する
@@ -62,29 +62,29 @@
   - 環境変数の各組み合わせパターン(全未設定、HEAP_SIZE のみ、全有効等)のユニットテストを作成する
   - 環境変数の各組み合わせパターン(全未設定、HEAP_SIZE のみ、全有効等)のユニットテストを作成する
   - _Requirements: 2.4, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
   - _Requirements: 2.4, 2.5, 2.6, 2.7, 6.1, 6.6, 6.7_
 
 
-- [ ] 4. マイグレーション実行とアプリプロセス管理
-- [ ] 4.1 マイグレーションの直接実行
+- [x] 4. マイグレーション実行とアプリプロセス管理
+- [x] 4.1 マイグレーションの直接実行
   - node バイナリを直接呼び出して migrate-mongo を実行する(npm run を使用しない、シェルを介さない)
   - node バイナリを直接呼び出して migrate-mongo を実行する(npm run を使用しない、シェルを介さない)
   - 標準入出力を inherit して migration のログを表示する
   - 標準入出力を inherit して migration のログを表示する
   - migration 失敗時は例外をキャッチしてプロセスを終了し、コンテナオーケストレーターによる再起動を促す
   - migration 失敗時は例外をキャッチしてプロセスを終了し、コンテナオーケストレーターによる再起動を促す
   - _Requirements: 6.5_
   - _Requirements: 6.5_
 
 
-- [ ] 4.2 アプリプロセスの起動とシグナル管理
+- [x] 4.2 アプリプロセスの起動とシグナル管理
   - 算出済みノードフラグを引数に含めた子プロセスとしてアプリケーションを起動する
   - 算出済みノードフラグを引数に含めた子プロセスとしてアプリケーションを起動する
   - SIGTERM、SIGINT、SIGHUP を子プロセスにフォワードする
   - SIGTERM、SIGINT、SIGHUP を子プロセスにフォワードする
   - 子プロセスの終了コード(またはシグナル)を entrypoint の終了コードとして伝播する
   - 子プロセスの終了コード(またはシグナル)を entrypoint の終了コードとして伝播する
   - PID 1 としての責務(シグナルフォワーディング、子プロセス reap、graceful shutdown)を検証するテストを作成する
   - PID 1 としての責務(シグナルフォワーディング、子プロセス reap、graceful shutdown)を検証するテストを作成する
   - _Requirements: 6.2, 6.5_
   - _Requirements: 6.2, 6.5_
 
 
-- [ ] 5. Dockerfile の再構築(現行 3 ステージパターン + DHI)
-- [ ] 5.1 (P) base ステージの構築
+- [x] 5. Dockerfile の再構築(現行 3 ステージパターン + DHI)
+- [x] 5.1 (P) base ステージの構築
   - DHI dev イメージをベースに設定し、syntax ディレクティブを最新安定版自動追従に更新する
   - DHI dev イメージをベースに設定し、syntax ディレクティブを最新安定版自動追従に更新する
   - wget スタンドアロンスクリプトで pnpm をインストールする(バージョンのハードコードを排除する)
   - wget スタンドアロンスクリプトで pnpm をインストールする(バージョンのハードコードを排除する)
   - turbo をグローバルにインストールする
   - turbo をグローバルにインストールする
   - ビルドに必要なパッケージを `--no-install-recommends` 付きでインストールし、apt キャッシュマウントを適用する
   - ビルドに必要なパッケージを `--no-install-recommends` 付きでインストールし、apt キャッシュマウントを適用する
   - _Requirements: 1.1, 1.2, 1.3, 1.5, 3.3, 4.4_
   - _Requirements: 1.1, 1.2, 1.3, 1.5, 3.3, 4.4_
 
 
-- [ ] 5.2 builder ステージの構築
+- [x] 5.2 builder ステージの構築
   - 現行の `COPY . .` パターンを維持してモノレポ全体をコピーし、依存インストール・ビルド・本番依存抽出を行う
   - 現行の `COPY . .` パターンを維持してモノレポ全体をコピーし、依存インストール・ビルド・本番依存抽出を行う
   - `--frozen-lockfile` の typo(ダッシュ3つ → 2つ)を修正する
   - `--frozen-lockfile` の typo(ダッシュ3つ → 2つ)を修正する
   - pnpm store のキャッシュマウントを設定してリビルド時間を短縮する
   - pnpm store のキャッシュマウントを設定してリビルド時間を短縮する
@@ -92,25 +92,25 @@
   - `.next/cache` がアーティファクトに含まれないことを保証する
   - `.next/cache` がアーティファクトに含まれないことを保証する
   - _Requirements: 1.4, 3.2, 3.4_
   - _Requirements: 1.4, 3.2, 3.4_
 
 
-- [ ] 5.3 release ステージの構築
+- [x] 5.3 release ステージの構築
   - DHI ランタイムイメージをベースに設定し、追加バイナリのコピーを一切行わない
   - DHI ランタイムイメージをベースに設定し、追加バイナリのコピーを一切行わない
   - ビルドステージのアーティファクトをバインドマウント経由で展開する
   - ビルドステージのアーティファクトをバインドマウント経由で展開する
   - TypeScript entrypoint ファイルを COPY し、ENTRYPOINT に node 経由の直接実行を設定する
   - TypeScript entrypoint ファイルを COPY し、ENTRYPOINT に node 経由の直接実行を設定する
   - リリースステージにビルドツール(turbo、pnpm、node-gyp 等)やビルド用パッケージ(wget、curl 等)が含まれないことを確認する
   - リリースステージにビルドツール(turbo、pnpm、node-gyp 等)やビルド用パッケージ(wget、curl 等)が含まれないことを確認する
   - _Requirements: 1.1, 3.5, 4.2, 4.5_
   - _Requirements: 1.1, 3.5, 4.2, 4.5_
 
 
-- [ ] 5.4 (P) OCI ラベルとポート・ボリューム宣言の設定
+- [x] 5.4 (P) OCI ラベルとポート・ボリューム宣言の設定
   - OCI 標準ラベル(source、title、description、vendor)を設定する
   - OCI 標準ラベル(source、title、description、vendor)を設定する
   - `EXPOSE 3000` と `VOLUME /data` を維持する
   - `EXPOSE 3000` と `VOLUME /data` を維持する
   - _Requirements: 5.1, 5.2, 5.3_
   - _Requirements: 5.1, 5.2, 5.3_
 
 
-- [ ] 6. 統合検証と後方互換性の確認
-- [ ] 6.1 Docker ビルドの E2E 検証
+- [x] 6. 統合検証と後方互換性の確認
+- [x] 6.1 Docker ビルドの E2E 検証
   - 3 ステージ全てが正常完了する Docker ビルドを実行し、ビルドエラーがないことを確認する
   - 3 ステージ全てが正常完了する Docker ビルドを実行し、ビルドエラーがないことを確認する
   - リリースイメージにシェル、apt、ビルドツールが含まれていないことを確認する
   - リリースイメージにシェル、apt、ビルドツールが含まれていないことを確認する
   - _Requirements: 1.1, 4.2, 4.5_
   - _Requirements: 1.1, 4.2, 4.5_
 
 
-- [ ] 6.2 ランタイム動作と後方互換性の検証
+- [x] 6.2 ランタイム動作と後方互換性の検証
   - 環境変数(`MONGO_URI`、`FILE_UPLOAD` 等)が従来通りアプリケーションに透過されることを確認する
   - 環境変数(`MONGO_URI`、`FILE_UPLOAD` 等)が従来通りアプリケーションに透過されることを確認する
   - `/data` ボリュームマウントとの互換性およびファイルアップロード動作を確認する
   - `/data` ボリュームマウントとの互換性およびファイルアップロード動作を確認する
   - ポート 3000 でのリッスン動作を確認する
   - ポート 3000 でのリッスン動作を確認する

+ 107 - 0
apps/app/docker-new/Dockerfile

@@ -0,0 +1,107 @@
+# syntax=docker/dockerfile:1
+
+ARG OPT_DIR="/opt"
+ARG PNPM_HOME="/root/.local/share/pnpm"
+
+##
+## base — DHI dev image with pnpm + turbo
+##
+FROM dhi.io/node:24-debian13-dev AS base
+
+ARG OPT_DIR
+ARG PNPM_HOME
+
+WORKDIR $OPT_DIR
+
+# Install build dependencies
+RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \
+    --mount=type=cache,target=/var/cache/apt,sharing=locked \
+  apt-get update && apt-get install -y --no-install-recommends ca-certificates wget
+
+# Install pnpm (standalone script, no version hardcoding)
+RUN wget -qO- https://get.pnpm.io/install.sh | ENV="$HOME/.shrc" SHELL="$(which sh)" sh -
+ENV PNPM_HOME=$PNPM_HOME
+ENV PATH="$PNPM_HOME:$PATH"
+
+# Install turbo globally
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add turbo --global
+
+
+
+##
+## builder — build + produce artifacts (current 3-stage COPY . . pattern)
+##
+FROM base AS builder
+
+ARG OPT_DIR
+ARG PNPM_HOME
+
+ENV PNPM_HOME=$PNPM_HOME
+ENV PATH="$PNPM_HOME:$PATH"
+
+WORKDIR $OPT_DIR
+
+COPY . .
+
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm add node-gyp --global
+RUN --mount=type=cache,target=$PNPM_HOME/store,sharing=locked \
+  pnpm install --frozen-lockfile
+
+# Build
+RUN turbo run clean
+RUN turbo run build --filter @growi/app
+
+# Produce artifacts
+RUN pnpm deploy out --prod --filter @growi/app
+RUN rm -rf apps/app/node_modules && mv out/node_modules apps/app/node_modules
+RUN rm -rf apps/app/.next/cache
+RUN tar -zcf /tmp/packages.tar.gz \
+  package.json \
+  apps/app/.next \
+  apps/app/config \
+  apps/app/dist \
+  apps/app/public \
+  apps/app/resource \
+  apps/app/tmp \
+  apps/app/.env.production* \
+  apps/app/next.config.js \
+  apps/app/package.json \
+  apps/app/node_modules
+
+
+
+##
+## release — DHI runtime (no shell, no additional binaries)
+##
+FROM dhi.io/node:24-debian13 AS release
+
+ARG OPT_DIR
+
+ENV NODE_ENV="production"
+ENV appDir="$OPT_DIR/growi"
+
+# Extract artifacts as node user
+USER node
+WORKDIR ${appDir}
+RUN --mount=type=bind,from=builder,source=/tmp/packages.tar.gz,target=/tmp/packages.tar.gz \
+  tar -zxf /tmp/packages.tar.gz -C ${appDir}/
+
+# Copy TypeScript entrypoint
+COPY --chown=node:node apps/app/docker-new/docker-entrypoint.ts /docker-entrypoint.ts
+
+# Switch back to root for entrypoint (it handles privilege drop)
+USER root
+WORKDIR ${appDir}/apps/app
+
+# OCI standard labels
+LABEL org.opencontainers.image.source="https://github.com/weseek/growi"
+LABEL org.opencontainers.image.title="GROWI"
+LABEL org.opencontainers.image.description="Team collaboration wiki using Markdown"
+LABEL org.opencontainers.image.vendor="WESEEK, Inc."
+
+VOLUME /data
+EXPOSE 3000
+
+ENTRYPOINT ["node", "/docker-entrypoint.ts"]

+ 50 - 0
apps/app/docker-new/Dockerfile.dockerignore

@@ -0,0 +1,50 @@
+# Dependencies and build caches
+**/node_modules
+**/.pnpm-store
+**/coverage
+**/.next
+**/.turbo
+out
+
+# Docker files (prevent recursive context)
+**/Dockerfile
+**/*.dockerignore
+
+# Git
+.git
+
+# IDE and editor settings
+.vscode
+.idea
+**/.DS_Store
+
+# Test files
+**/*.spec.*
+**/*.test.*
+**/test/
+**/__tests__/
+**/playwright/
+
+# Documentation (not needed for build)
+**/*.md
+!**/README.md
+
+# Environment files (secrets)
+.env
+.env.*
+!.env.production
+!.env.production.local
+
+# Unrelated apps
+apps/slackbot-proxy
+apps/pdf-converter
+
+# CI/CD and config
+.github
+.circleci
+**/.eslintrc*
+**/.prettierrc*
+**/biome.json
+**/tsconfig*.json
+!apps/app/tsconfig*.json
+!packages/*/tsconfig*.json

+ 358 - 0
apps/app/docker-new/docker-entrypoint.spec.ts

@@ -0,0 +1,358 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+  buildNodeFlags,
+  chownRecursive,
+  detectHeapSize,
+  readCgroupLimit,
+  setupDirectories,
+} from './docker-entrypoint';
+
+describe('chownRecursive', () => {
+  let tmpDir: string;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-test-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  it('should chown a flat directory', () => {
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    chownRecursive(tmpDir, 1000, 1000);
+    // Should chown the directory itself
+    expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
+    chownSyncSpy.mockRestore();
+  });
+
+  it('should chown nested directories and files recursively', () => {
+    // Create nested structure
+    const subDir = path.join(tmpDir, 'sub');
+    fs.mkdirSync(subDir);
+    fs.writeFileSync(path.join(tmpDir, 'file1.txt'), 'hello');
+    fs.writeFileSync(path.join(subDir, 'file2.txt'), 'world');
+
+    const chownedPaths: string[] = [];
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation((p) => {
+      chownedPaths.push(p as string);
+    });
+
+    chownRecursive(tmpDir, 1000, 1000);
+
+    expect(chownedPaths).toContain(tmpDir);
+    expect(chownedPaths).toContain(subDir);
+    expect(chownedPaths).toContain(path.join(tmpDir, 'file1.txt'));
+    expect(chownedPaths).toContain(path.join(subDir, 'file2.txt'));
+    expect(chownedPaths).toHaveLength(4);
+
+    chownSyncSpy.mockRestore();
+  });
+
+  it('should handle empty directory', () => {
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    chownRecursive(tmpDir, 1000, 1000);
+    // Should only chown the directory itself
+    expect(chownSyncSpy).toHaveBeenCalledTimes(1);
+    expect(chownSyncSpy).toHaveBeenCalledWith(tmpDir, 1000, 1000);
+    chownSyncSpy.mockRestore();
+  });
+});
+
+describe('readCgroupLimit', () => {
+  it('should read cgroup v2 numeric limit', () => {
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockReturnValue('1073741824\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBe(1073741824);
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for cgroup v2 "max" (unlimited)', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('max\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined when file does not exist', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for NaN content', () => {
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockReturnValue('invalid\n');
+    const result = readCgroupLimit('/sys/fs/cgroup/memory.max');
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+});
+
+describe('detectHeapSize', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('should use GROWI_HEAP_SIZE when set', () => {
+    process.env.GROWI_HEAP_SIZE = '512';
+    const readSpy = vi.spyOn(fs, 'readFileSync');
+    const result = detectHeapSize();
+    expect(result).toBe(512);
+    // Should not attempt to read cgroup files
+    expect(readSpy).not.toHaveBeenCalled();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for invalid GROWI_HEAP_SIZE', () => {
+    process.env.GROWI_HEAP_SIZE = 'abc';
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined for empty GROWI_HEAP_SIZE', () => {
+    process.env.GROWI_HEAP_SIZE = '';
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should auto-calculate from cgroup v2 at 60%', () => {
+    delete process.env.GROWI_HEAP_SIZE;
+    // 1GB = 1073741824 bytes → 60% ≈ 614 MB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return '1073741824\n';
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBe(Math.floor((1073741824 / 1024 / 1024) * 0.6));
+    readSpy.mockRestore();
+  });
+
+  it('should fallback to cgroup v1 when v2 is unlimited', () => {
+    delete process.env.GROWI_HEAP_SIZE;
+    // v2 = max (unlimited), v1 = 2GB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
+        if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
+          return '2147483648\n';
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBe(Math.floor((2147483648 / 1024 / 1024) * 0.6));
+    readSpy.mockRestore();
+  });
+
+  it('should treat cgroup v1 > 64GB as unlimited', () => {
+    delete process.env.GROWI_HEAP_SIZE;
+    const hugeValue = 128 * 1024 * 1024 * 1024; // 128GB
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockImplementation((filePath) => {
+        if (filePath === '/sys/fs/cgroup/memory.max') return 'max\n';
+        if (filePath === '/sys/fs/cgroup/memory/memory.limit_in_bytes')
+          return `${hugeValue}\n`;
+        throw new Error('ENOENT');
+      });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should return undefined when no cgroup limits detected', () => {
+    delete process.env.GROWI_HEAP_SIZE;
+    const readSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
+      throw new Error('ENOENT');
+    });
+    const result = detectHeapSize();
+    expect(result).toBeUndefined();
+    readSpy.mockRestore();
+  });
+
+  it('should prioritize GROWI_HEAP_SIZE over cgroup', () => {
+    process.env.GROWI_HEAP_SIZE = '256';
+    const readSpy = vi
+      .spyOn(fs, 'readFileSync')
+      .mockReturnValue('1073741824\n');
+    const result = detectHeapSize();
+    expect(result).toBe(256);
+    // Should not have read cgroup files
+    expect(readSpy).not.toHaveBeenCalled();
+    readSpy.mockRestore();
+  });
+});
+
+describe('buildNodeFlags', () => {
+  const originalEnv = process.env;
+
+  beforeEach(() => {
+    process.env = { ...originalEnv };
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  it('should always include --expose_gc', () => {
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--expose_gc');
+  });
+
+  it('should include --max-heap-size when heapSize is provided', () => {
+    const flags = buildNodeFlags(512);
+    expect(flags).toContain('--max-heap-size=512');
+  });
+
+  it('should not include --max-heap-size when heapSize is undefined', () => {
+    const flags = buildNodeFlags(undefined);
+    expect(flags.some((f) => f.startsWith('--max-heap-size'))).toBe(false);
+  });
+
+  it('should include --optimize-for-size when GROWI_OPTIMIZE_MEMORY=true', () => {
+    process.env.GROWI_OPTIMIZE_MEMORY = 'true';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--optimize-for-size');
+  });
+
+  it('should not include --optimize-for-size when GROWI_OPTIMIZE_MEMORY is not true', () => {
+    process.env.GROWI_OPTIMIZE_MEMORY = 'false';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).not.toContain('--optimize-for-size');
+  });
+
+  it('should include --lite-mode when GROWI_LITE_MODE=true', () => {
+    process.env.GROWI_LITE_MODE = 'true';
+    const flags = buildNodeFlags(undefined);
+    expect(flags).toContain('--lite-mode');
+  });
+
+  it('should not include --lite-mode when GROWI_LITE_MODE is not true', () => {
+    delete process.env.GROWI_LITE_MODE;
+    const flags = buildNodeFlags(undefined);
+    expect(flags).not.toContain('--lite-mode');
+  });
+
+  it('should combine all flags when all options enabled', () => {
+    process.env.GROWI_OPTIMIZE_MEMORY = 'true';
+    process.env.GROWI_LITE_MODE = 'true';
+    const flags = buildNodeFlags(256);
+    expect(flags).toContain('--expose_gc');
+    expect(flags).toContain('--max-heap-size=256');
+    expect(flags).toContain('--optimize-for-size');
+    expect(flags).toContain('--lite-mode');
+  });
+
+  it('should not use --max_old_space_size', () => {
+    const flags = buildNodeFlags(512);
+    expect(flags.some((f) => f.includes('max_old_space_size'))).toBe(false);
+  });
+});
+
+describe('setupDirectories', () => {
+  let tmpDir: string;
+
+  beforeEach(() => {
+    tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'entrypoint-setup-'));
+  });
+
+  afterEach(() => {
+    fs.rmSync(tmpDir, { recursive: true, force: true });
+  });
+
+  it('should create uploads directory and symlink', () => {
+    const uploadsDir = path.join(tmpDir, 'data', 'uploads');
+    const publicUploads = path.join(tmpDir, 'public', 'uploads');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      uploadsDir,
+      publicUploads,
+      path.join(tmpDir, 'bulk-export'),
+    );
+
+    expect(fs.existsSync(uploadsDir)).toBe(true);
+    expect(fs.lstatSync(publicUploads).isSymbolicLink()).toBe(true);
+    expect(fs.readlinkSync(publicUploads)).toBe(uploadsDir);
+
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+
+  it('should not recreate symlink if it already exists', () => {
+    const uploadsDir = path.join(tmpDir, 'data', 'uploads');
+    const publicUploads = path.join(tmpDir, 'public', 'uploads');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+    fs.mkdirSync(uploadsDir, { recursive: true });
+    fs.symlinkSync(uploadsDir, publicUploads);
+
+    const symlinkSpy = vi.spyOn(fs, 'symlinkSync');
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      uploadsDir,
+      publicUploads,
+      path.join(tmpDir, 'bulk-export'),
+    );
+
+    expect(symlinkSpy).not.toHaveBeenCalled();
+
+    symlinkSpy.mockRestore();
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+
+  it('should create bulk export directory with permissions', () => {
+    const bulkExportDir = path.join(tmpDir, 'bulk-export');
+    fs.mkdirSync(path.join(tmpDir, 'public'), { recursive: true });
+    const chownSyncSpy = vi.spyOn(fs, 'chownSync').mockImplementation(() => {});
+    const lchownSyncSpy = vi
+      .spyOn(fs, 'lchownSync')
+      .mockImplementation(() => {});
+
+    setupDirectories(
+      path.join(tmpDir, 'data', 'uploads'),
+      path.join(tmpDir, 'public', 'uploads'),
+      bulkExportDir,
+    );
+
+    expect(fs.existsSync(bulkExportDir)).toBe(true);
+    const stat = fs.statSync(bulkExportDir);
+    expect(stat.mode & 0o777).toBe(0o700);
+
+    chownSyncSpy.mockRestore();
+    lchownSyncSpy.mockRestore();
+  });
+});

+ 265 - 0
apps/app/docker-new/docker-entrypoint.ts

@@ -0,0 +1,265 @@
+/**
+ * Docker entrypoint for GROWI (TypeScript)
+ *
+ * Runs directly with Node.js 24 native type stripping.
+ * Uses only erasable TypeScript syntax (no enums, no namespaces).
+ *
+ * Responsibilities:
+ * - Directory setup (as root): /data/uploads, symlinks, /tmp/page-bulk-export
+ * - Heap size detection: GROWI_HEAP_SIZE → cgroup auto-calc → V8 default
+ * - Privilege drop: process.setgid + process.setuid (root → node)
+ * - Migration execution: execFileSync (no shell)
+ * - App process spawn: spawn with signal forwarding
+ */
+
+/** biome-ignore-all lint/suspicious/noConsole: Allow printing to console */
+
+import { execFileSync, spawn } from 'node:child_process';
+import fs from 'node:fs';
+
+// -- Constants --
+
+const NODE_UID = 1000;
+const NODE_GID = 1000;
+const CGROUP_V2_PATH = '/sys/fs/cgroup/memory.max';
+const CGROUP_V1_PATH = '/sys/fs/cgroup/memory/memory.limit_in_bytes';
+const CGROUP_V1_UNLIMITED_THRESHOLD = 64 * 1024 * 1024 * 1024; // 64GB
+const HEAP_RATIO = 0.6;
+
+// -- Exported utility functions --
+
+/**
+ * Recursively chown a directory and all its contents.
+ */
+export function chownRecursive(
+  dirPath: string,
+  uid: number,
+  gid: number,
+): void {
+  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
+  for (const entry of entries) {
+    const fullPath = `${dirPath}/${entry.name}`;
+    if (entry.isDirectory()) {
+      chownRecursive(fullPath, uid, gid);
+    } else {
+      fs.chownSync(fullPath, uid, gid);
+    }
+  }
+  fs.chownSync(dirPath, uid, gid);
+}
+
+/**
+ * Read a cgroup memory limit file and return the numeric value in bytes.
+ * Returns undefined if the file cannot be read or the value is "max" / NaN.
+ */
+export function readCgroupLimit(filePath: string): number | undefined {
+  try {
+    const content = fs.readFileSync(filePath, 'utf-8').trim();
+    if (content === 'max') return undefined;
+    const value = parseInt(content, 10);
+    if (Number.isNaN(value)) return undefined;
+    return value;
+  } catch {
+    return undefined;
+  }
+}
+
+/**
+ * Detect heap size (MB) using 3-level fallback:
+ * 1. GROWI_HEAP_SIZE env var
+ * 2. cgroup v2/v1 auto-calculation (60% of limit)
+ * 3. undefined (V8 default)
+ */
+export function detectHeapSize(): number | undefined {
+  // Priority 1: GROWI_HEAP_SIZE env
+  const envValue = process.env.GROWI_HEAP_SIZE;
+  if (envValue != null && envValue !== '') {
+    const parsed = parseInt(envValue, 10);
+    if (Number.isNaN(parsed)) {
+      console.error(
+        `[entrypoint] GROWI_HEAP_SIZE="${envValue}" is not a valid number, ignoring`,
+      );
+      return undefined;
+    }
+    return parsed;
+  }
+
+  // Priority 2: cgroup v2
+  const cgroupV2 = readCgroupLimit(CGROUP_V2_PATH);
+  if (cgroupV2 != null) {
+    return Math.floor((cgroupV2 / 1024 / 1024) * HEAP_RATIO);
+  }
+
+  // Priority 3: cgroup v1 (treat > 64GB as unlimited)
+  const cgroupV1 = readCgroupLimit(CGROUP_V1_PATH);
+  if (cgroupV1 != null && cgroupV1 < CGROUP_V1_UNLIMITED_THRESHOLD) {
+    return Math.floor((cgroupV1 / 1024 / 1024) * HEAP_RATIO);
+  }
+
+  // Priority 4: V8 default
+  return undefined;
+}
+
+/**
+ * Build Node.js flags array based on heap size and environment variables.
+ */
+export function buildNodeFlags(heapSize: number | undefined): string[] {
+  const flags: string[] = ['--expose_gc'];
+
+  if (heapSize != null) {
+    flags.push(`--max-heap-size=${heapSize}`);
+  }
+
+  if (process.env.GROWI_OPTIMIZE_MEMORY === 'true') {
+    flags.push('--optimize-for-size');
+  }
+
+  if (process.env.GROWI_LITE_MODE === 'true') {
+    flags.push('--lite-mode');
+  }
+
+  return flags;
+}
+
+/**
+ * Setup required directories (as root).
+ * - /data/uploads with symlink to ./public/uploads
+ * - /tmp/page-bulk-export with mode 700
+ */
+export function setupDirectories(
+  uploadsDir: string,
+  publicUploadsLink: string,
+  bulkExportDir: string,
+): void {
+  // /data/uploads
+  fs.mkdirSync(uploadsDir, { recursive: true });
+  if (!fs.existsSync(publicUploadsLink)) {
+    fs.symlinkSync(uploadsDir, publicUploadsLink);
+  }
+  chownRecursive(uploadsDir, NODE_UID, NODE_GID);
+  fs.lchownSync(publicUploadsLink, NODE_UID, NODE_GID);
+
+  // /tmp/page-bulk-export
+  fs.mkdirSync(bulkExportDir, { recursive: true });
+  chownRecursive(bulkExportDir, NODE_UID, NODE_GID);
+  fs.chmodSync(bulkExportDir, 0o700);
+}
+
+/**
+ * Drop privileges from root to node user.
+ * These APIs are POSIX-only and guaranteed to exist in the Docker container (Linux).
+ */
+export function dropPrivileges(): void {
+  if (process.setgid == null || process.setuid == null) {
+    throw new Error('Privilege drop APIs not available (non-POSIX platform)');
+  }
+  process.setgid(NODE_GID);
+  process.setuid(NODE_UID);
+}
+
+/**
+ * Log applied Node.js flags to stdout.
+ */
+function logFlags(heapSize: number | undefined, flags: string[]): void {
+  const source = (() => {
+    if (
+      process.env.GROWI_HEAP_SIZE != null &&
+      process.env.GROWI_HEAP_SIZE !== ''
+    ) {
+      return 'GROWI_HEAP_SIZE env';
+    }
+    if (heapSize != null) return 'cgroup auto-detection';
+    return 'V8 default (no heap limit)';
+  })();
+
+  console.log(`[entrypoint] Heap size source: ${source}`);
+  console.log(`[entrypoint] Node.js flags: ${flags.join(' ')}`);
+}
+
+/**
+ * Run database migration via execFileSync (no shell needed).
+ * Equivalent to: node -r dotenv-flow/config node_modules/migrate-mongo/bin/migrate-mongo up -f config/migrate-mongo-config.js
+ */
+function runMigration(): void {
+  console.log('[entrypoint] Running migration...');
+  execFileSync(
+    process.execPath,
+    [
+      '-r',
+      'dotenv-flow/config',
+      'node_modules/migrate-mongo/bin/migrate-mongo',
+      'up',
+      '-f',
+      'config/migrate-mongo-config.js',
+    ],
+    {
+      stdio: 'inherit',
+      env: { ...process.env, NODE_ENV: 'production' },
+    },
+  );
+  console.log('[entrypoint] Migration completed');
+}
+
+/**
+ * Spawn the application process and forward signals.
+ */
+function spawnApp(nodeFlags: string[]): void {
+  const child = spawn(
+    process.execPath,
+    [...nodeFlags, '-r', 'dotenv-flow/config', 'dist/server/app.js'],
+    {
+      stdio: 'inherit',
+      env: { ...process.env, NODE_ENV: 'production' },
+    },
+  );
+
+  // PID 1 signal forwarding
+  const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGHUP'];
+  for (const sig of signals) {
+    process.on(sig, () => child.kill(sig));
+  }
+
+  child.on('exit', (code: number | null, signal: NodeJS.Signals | null) => {
+    process.exit(code ?? (signal === 'SIGTERM' ? 0 : 1));
+  });
+}
+
+// -- Main entrypoint --
+
+function main(): void {
+  try {
+    // Step 1: Directory setup (as root)
+    setupDirectories(
+      '/data/uploads',
+      './public/uploads',
+      '/tmp/page-bulk-export',
+    );
+
+    // Step 2: Detect heap size and build flags
+    const heapSize = detectHeapSize();
+    const nodeFlags = buildNodeFlags(heapSize);
+    logFlags(heapSize, nodeFlags);
+
+    // Step 3: Drop privileges (root → node)
+    dropPrivileges();
+
+    // Step 4: Run migration
+    runMigration();
+
+    // Step 5: Start application
+    spawnApp(nodeFlags);
+  } catch (err) {
+    console.error('[entrypoint] Fatal error:', err);
+    process.exit(1);
+  }
+}
+
+// Run main only when executed directly (not when imported for testing)
+const isMainModule =
+  process.argv[1] != null &&
+  (process.argv[1].endsWith('docker-entrypoint.ts') ||
+    process.argv[1].endsWith('docker-entrypoint.js'));
+
+if (isMainModule) {
+  main();
+}