From 512eb7e20e9def6bfefaee01484bad64d37e6830 Mon Sep 17 00:00:00 2001 From: dendencat Date: Sun, 19 Jul 2026 00:10:49 +0900 Subject: [PATCH 1/6] Restore CSRF protection on login and markdown preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove @csrf_exempt from login_view and preview_markdown_view, and send X-CSRFToken from the editor's preview fetch (token read from the form's hidden input, same pattern as dashboard.html). Also remove the {% if not IS_TESTING %} guards that stripped {% csrf_token %} out of the login and editor forms during tests — the hack existed only to make token-less test POSTs pass, which is exactly backwards. The testing_mode context processor existed solely for that guard and is deleted with it. New tests assert both endpoints reject token-less POSTs with enforce_csrf_checks enabled. Co-Authored-By: Claude Fable 5 --- techblog_cms/context_processors.py | 10 ------ techblog_cms/settings.py | 1 - techblog_cms/templates/article_editor.html | 8 +++-- techblog_cms/templates/login.html | 2 -- techblog_cms/tests/test_csrf.py | 37 ++++++++++++++++++++++ techblog_cms/views.py | 9 ++---- 6 files changed, 44 insertions(+), 23 deletions(-) create mode 100644 techblog_cms/tests/test_csrf.py diff --git a/techblog_cms/context_processors.py b/techblog_cms/context_processors.py index 0a0e71c..e219fdb 100644 --- a/techblog_cms/context_processors.py +++ b/techblog_cms/context_processors.py @@ -1,6 +1,3 @@ -import os -import sys - from django.core.cache import cache from .models import Category, Tag @@ -8,13 +5,6 @@ SIDEBAR_CACHE_TTL = 300 -def testing_mode(request): - """Add IS_TESTING variable to template context""" - IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or any( - x.endswith('pytest') for x in sys.modules.keys() - ) - return {'IS_TESTING': IS_TESTING} - def sidebar(request): categories = cache.get('sidebar:categories') diff --git a/techblog_cms/settings.py b/techblog_cms/settings.py index 331e7a9..490604b 100644 --- a/techblog_cms/settings.py +++ b/techblog_cms/settings.py @@ -55,7 +55,6 @@ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - 'techblog_cms.context_processors.testing_mode', 'techblog_cms.context_processors.sidebar', ], }, diff --git a/techblog_cms/templates/article_editor.html b/techblog_cms/templates/article_editor.html index df96afd..ed9730a 100644 --- a/techblog_cms/templates/article_editor.html +++ b/techblog_cms/templates/article_editor.html @@ -12,9 +12,7 @@

{% if article %}記事編集{% else %}記 {% endif %}
- {% if not IS_TESTING %} {% csrf_token %} - {% endif %}
@@ -236,9 +234,13 @@

ショートカットキー

async function serverRenderMarkdown(markdown) { try { + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); const resp = await fetch('/api/preview_markdown/', { method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrfToken ? csrfToken.value : '' + }, body: 'text=' + encodeURIComponent(markdown || '') }); if (!resp.ok) throw new Error('HTTP ' + resp.status); diff --git a/techblog_cms/templates/login.html b/techblog_cms/templates/login.html index 088435a..fcb8332 100644 --- a/techblog_cms/templates/login.html +++ b/techblog_cms/templates/login.html @@ -24,9 +24,7 @@

ログイン

{{ error }}
{% endif %} - {% if not IS_TESTING %} {% csrf_token %} - {% endif %}
diff --git a/techblog_cms/tests/test_csrf.py b/techblog_cms/tests/test_csrf.py new file mode 100644 index 0000000..0c56027 --- /dev/null +++ b/techblog_cms/tests/test_csrf.py @@ -0,0 +1,37 @@ +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse + + +class CsrfProtectionTests(TestCase): + """The custom login and preview endpoints must reject requests without a CSRF token.""" + + def setUp(self): + self.user = User.objects.create_user(username="editor", password="pass1234") + self.csrf_client = Client(enforce_csrf_checks=True) + + def test_login_post_without_token_is_rejected(self): + response = self.csrf_client.post( + reverse("login"), + {"username": "editor", "password": "pass1234"}, + ) + self.assertEqual(response.status_code, 403) + + def test_preview_post_without_token_is_rejected(self): + self.csrf_client.force_login(self.user) + response = self.csrf_client.post(reverse("preview_markdown"), {"text": "# hi"}) + self.assertEqual(response.status_code, 403) + + def test_preview_post_with_token_succeeds(self): + self.csrf_client.force_login(self.user) + editor_page = self.csrf_client.get(reverse("article_new")) + self.assertEqual(editor_page.status_code, 200) + token = self.csrf_client.cookies["csrftoken"].value + + response = self.csrf_client.post( + reverse("preview_markdown"), + {"text": "# hi"}, + HTTP_X_CSRFTOKEN=token, + ) + self.assertEqual(response.status_code, 200) + self.assertIn("html", response.json()) diff --git a/techblog_cms/views.py b/techblog_cms/views.py index dfcca22..23cee38 100644 --- a/techblog_cms/views.py +++ b/techblog_cms/views.py @@ -5,7 +5,6 @@ from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods -from django.views.decorators.csrf import csrf_exempt from django.core.paginator import Paginator from django.core.cache import cache from django.core.files.base import ContentFile @@ -222,7 +221,6 @@ def process_article_image(uploaded_file): @require_http_methods(["GET", "POST"]) -@csrf_exempt def login_view(request): if request.method == 'POST': username = request.POST.get('username') @@ -372,14 +370,11 @@ def render_editor(title_value, content_value, error=None, image_error=None): @login_required -@require_http_methods(["POST"]) -@csrf_exempt +@require_http_methods(["POST"]) def preview_markdown_view(request): """Render markdown to HTML for live preview using the same pipeline as production. - Notes: - - CSRF exempt to simplify AJAX preview while authenticated. - - Returns JSON: { html: "" } + Returns JSON: { html: "" } """ text = request.POST.get('text', '') or '' html = markdown_to_html(text) From e4a15bf4c749daa975b3a6fd5b7828fdf7a4eb61 Mon Sep 17 00:00:00 2001 From: dendencat Date: Sun, 19 Jul 2026 00:12:50 +0900 Subject: [PATCH 2/6] Run CI tests against the PostgreSQL service instead of SQLite The test job has always started a postgres service container but the suite silently ran on in-memory SQLite via the IS_TESTING branch, so migrations, indexes, and type behaviour were never exercised on the production engine. TEST_DB_ENGINE=postgres now opts the test settings into the service container; local pytest keeps the fast SQLite default. Verified: full suite passes on both engines. Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-publish.yml | 3 +++ techblog_cms/settings.py | 25 +++++++++++++++++++------ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 0e802a5..7dfeec4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -68,6 +68,9 @@ jobs: SECRET_KEY: test-key DEBUG: 'False' ALLOWED_HOSTS: localhost,127.0.0.1,testserver,blog.iohub.link + # Run the suite against the same engine as production (service container above) + TEST_DB_ENGINE: postgres + POSTGRES_HOST: localhost run: | python -m pytest -v --cov=techblog_cms --cov-report=xml diff --git a/techblog_cms/settings.py b/techblog_cms/settings.py index 490604b..da94b6c 100644 --- a/techblog_cms/settings.py +++ b/techblog_cms/settings.py @@ -71,13 +71,26 @@ logger.debug("IS_TESTING: %s", IS_TESTING) if IS_TESTING: - # Testing uses SQLite and in-memory cache for simplicity and isolation. - DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', + # Tests default to SQLite for speed; set TEST_DB_ENGINE=postgres (as CI + # does) to run them against the same engine as production. + if os.environ.get('TEST_DB_ENGINE') == 'postgres': + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('POSTGRES_DB', 'techblogdb'), + 'USER': os.environ.get('POSTGRES_USER', 'techblog'), + 'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'techblogpass'), + 'HOST': os.environ.get('POSTGRES_HOST', 'localhost'), + 'PORT': os.environ.get('POSTGRES_PORT', '5432'), + } + } + else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } } - } CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', From 7b1db28f133de0b39df28c1e29a11bb1744966a5 Mon Sep 17 00:00:00 2001 From: dendencat Date: Sun, 19 Jul 2026 00:13:34 +0900 Subject: [PATCH 3/6] Add a compose smoke job that boots and probes the real stack in CI Until now no pipeline ever ran the deployable stack end-to-end, which let a mutually inconsistent .env, an unbuildable nginx image, and a healthcheck that structurally failed under DEBUG=False all go unnoticed for months. The smoke job generates a self-consistent .env, brings up db + redis + django with compose, waits for the container healthcheck, and asserts /health/, /ready/, / and /articles/ return 200. nginx stays out because its config needs TLS certificates that only exist on the production host. Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-publish.yml | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7dfeec4..8def130 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -74,6 +74,60 @@ jobs: run: | python -m pytest -v --cov=techblog_cms --cov-report=xml + # Boot the real compose stack (db + redis + django) and hit it end-to-end, + # so entrypoint, migrations, cache auth, and the container healthcheck are + # proven to work together on every PR. nginx is excluded: its config + # requires TLS certificates that only exist on the production host. + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Generate a self-consistent .env + run: | + cat > .env <<'ENV' + SECRET_KEY=ci-smoke-secret-key + DEBUG=False + ALLOWED_HOSTS=localhost,127.0.0.1,django + POSTGRES_DB=techblogdb + POSTGRES_USER=techblog + POSTGRES_PASSWORD=techblogpass + DATABASE_URL=postgres://techblog:techblogpass@db:5432/techblogdb + REDIS_PASSWORD=ci-smoke-redis-pass + REDIS_URL=redis://redis:6379/1 + ENV + + - name: Start db, redis, and django + run: docker compose up -d --build db redis django + + - name: Wait for the django healthcheck + run: | + cid=$(docker compose ps -q django) + for i in $(seq 1 30); do + status=$(docker inspect -f '{{.State.Health.Status}}' "$cid" 2>/dev/null || echo starting) + [ "$status" = healthy ] && exit 0 + sleep 5 + done + echo "django never became healthy (last status: $status)" + docker compose logs django + exit 1 + + - name: Probe the running application + run: | + docker compose exec -T django python - <<'PY' + import requests + secure = {'X-Forwarded-Proto': 'https'} + for path, headers in [('/health/', {}), ('/ready/', {}), ('/', secure), ('/articles/', secure)]: + r = requests.get('http://localhost:8000' + path, headers=headers, + allow_redirects=False, timeout=15) + print(path, r.status_code) + assert r.status_code == 200, f'{path} returned {r.status_code}' + PY + + - name: Show logs on failure + if: failure() + run: docker compose logs django db redis + build: needs: test runs-on: ubuntu-latest From b60c544e7dade0afd4c61e52c15237e5d565f9d7 Mon Sep 17 00:00:00 2001 From: dendencat Date: Sun, 19 Jul 2026 00:14:06 +0900 Subject: [PATCH 4/6] Clean up stray AI-agent artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete assitant.agent.prompt.md (typo'd filename, referenced nowhere) and move copilot-instructions.md out of .github/workflows/ — Actions ignores non-workflow files there, while .github/copilot-instructions.md is the location Copilot actually reads. Co-Authored-By: Claude Fable 5 --- .../{workflows => }/copilot-instructions.md | 0 assitant.agent.prompt.md | 90 ------------------- 2 files changed, 90 deletions(-) rename .github/{workflows => }/copilot-instructions.md (100%) delete mode 100644 assitant.agent.prompt.md diff --git a/.github/workflows/copilot-instructions.md b/.github/copilot-instructions.md similarity index 100% rename from .github/workflows/copilot-instructions.md rename to .github/copilot-instructions.md diff --git a/assitant.agent.prompt.md b/assitant.agent.prompt.md deleted file mode 100644 index 6bf2c3e..0000000 --- a/assitant.agent.prompt.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -description: New prompt created from chat session -mode: agent -tools: ['extensions', 'codebase', 'usages', 'vscodeAPI', 'problems', 'changes', 'testFailure', 'terminalSelection', 'terminalLastCommand', 'openSimpleBrowser', 'fetch', 'findTestFiles', 'searchResults', 'githubRepo', 'getPythonEnvironmentInfo', 'getPythonExecutableCommand', 'installPythonPackage', 'configurePythonEnvironment', 'runTests', 'runCommands', 'runTasks', 'weather', 'editFiles', 'runNotebooks', 'search', 'new'] -model: Grok Code Fast 1 (Preview) ---- - -このレポジトリの内容を解析し、どういう実装がされているか解説してください。 - - -このレポジトリは、DjangoベースのTech Blog CMS(コンテンツ管理システム)の実装例です。Docker Composeを使用してコンテナ化され、Nginxによるリバースプロキシ、PostgreSQLデータベース、Redisキャッシュ、Let's EncryptによるSSL証明書の自動取得・更新を統合しています。以下に主要な実装内容を解説します。 - -### 全体アーキテクチャ -- **Webフレームワーク**: Django 4.2を使用。WSGIアプリケーションとしてGunicornで実行。 -- **コンテナ化**: Docker Composeで複数サービスを管理(Djangoアプリ、Nginx、PostgreSQL、Redis、Certbot)。 -- **セキュリティ**: HTTPS強制、セキュリティヘッダー、CSRF保護、環境変数による設定管理、強力なパスワード要件。 -- **CI/CD**: GitHub Actionsでテスト、ビルド、脆弱性スキャン(Trivy)を自動化。 -- **テスト**: pytestを使用したユニットテストと統合テスト。 - -### 主要コンポーネントの解説 -1. **Djangoアプリケーション (techblog_cms/)**: - - **モデル (techblog_cms/models.py)**: Category, Tag, Articleモデルを実装。記事の作成・管理、カテゴリ分類、タグ付け機能をサポート。 - - **ビュー (techblog_cms/views.py)**: - - `health_check`: JSONレスポンスでヘルスチェック。 - - `home_view`: 公開記事のトップ10を表示。 - - `article_list_view`: 全公開記事を表示。 - - `categories_view`: カテゴリ一覧を表示。 - - `category_view`: カテゴリ別記事を表示。 - - `article_detail_view`: 記事詳細を表示(ログイン時は下書きも表示)。 - - `login_view`: ユーザー認証(CSRF保護付き)。 - - `logout_view`: ログアウト処理。 - - `dashboard_view`: 管理ダッシュボード(ページネーション付き記事一覧)。 - - `article_editor_view`: 記事作成・編集。 - - `article_delete_view`: 記事削除。 - - `admin_guard`: 管理画面アクセス制御。 - - **URL設定 (techblog_cms/urls.py)**: 記事管理、カテゴリ、ログイン/ログアウト、管理画面などのURLマッピング。 - - **設定 (techblog_cms/settings.py)**: 環境変数でDEBUG、ALLOWED_HOSTS、DB接続を制御。PostgreSQLとRedisを統合。テストモードでのCSRF無効化。 - - **テンプレート**: - - `home.html`: ホーム画面。 - - `article_list.html`: 記事一覧。 - - `category_list.html`: カテゴリ一覧。 - - `category_detail.html`: カテゴリ詳細。 - - `article_detail.html`: 記事詳細。 - - `login.html`: ログインフォーム。 - - `dashboard.html`: 管理ダッシュボード(ページネーション付き)。 - - `article_editor.html`: 記事編集フォーム。 - - `article_delete_confirm.html`: 削除確認。 - - `article_delete_success.html`: 削除成功。 - - **WSGI (techblog_cms/wsgi.py)**: Djangoアプリのエントリーポイント。 - -2. **Docker設定**: - - **Djangoコンテナ (Dockerfile.django)**: Python 3.11ベースで依存関係をインストールし、非rootユーザーで実行。 - - **Nginxコンテナ (Dockerfile.nginx)**: 静的ファイル配信とリバースプロキシ。SSL証明書をマウント。 - - **Compose (docker-compose.yml)**: サービス間ネットワーク、ボリューム、環境変数を定義。Certbotで証明書更新を自動化。 - -3. **データベースとキャッシュ**: - - PostgreSQL: コンテナで実行され、永続ボリュームでデータを保持。 - - Redis: パスワード認証付きでキャッシュを提供。 - -4. **SSL証明書管理**: - - **初期化スクリプト (scripts/init-letsencrypt.sh)**: CertbotでLet's Encrypt証明書を取得し、Nginxに適用。 - - **更新スクリプト (scripts/renew-cert.sh)**: 証明書を定期更新。 - -5. **テストと品質管理**: - - **テスト設定 (conftest.py, pytest.ini)**: Djangoテスト環境をSQLiteでセットアップ。 - - **テストケース (techblog_cms/tests/test_views.py)**: ホーム画面のステータスコードとテンプレート使用を検証。 - - **CIパイプライン (.github/workflows/docker-publish.yml)**: プッシュ時にテストを実行し、イメージをビルド・プッシュ。 - -6. **その他のファイル**: - - **デプロイスクリプト (deploy.sh)**: Gitプル、コンテナ再ビルド、起動を自動化。 - - **環境設定 (.env.example)**: 秘密鍵やDB接続を環境変数で管理。 - - **ドキュメント (README.md)**: セットアップ手順、技術スタック、ライセンスを記載。 - -この実装は、プロダクションレディなDjangoアプリのベストプラクティスを反映しており、セキュリティとスケーラビリティを重視しています。記事管理機能、ページネーション、ユーザー認証、CSRF保護などの機能を備えています。 - - -AIへの指示とレポジトリの理解のため、AGENTS.mdの作成を行ってください。 - - -AGENTS.mdファイルを作成しました。このファイルには、レポジトリの概要、技術スタック、プロジェクト構造、開発ガイドライン、AIアシスタント向けの具体的な指示をまとめています。これにより、AIがこのプロジェクトを効果的に支援できるようになります。 - -主な内容: -- レポジトリの目的と技術スタックの説明 -- プロジェクト構造の詳細 -- コーディング標準とセキュリティ考慮事項 -- AIへのコード生成・支援時のガイドライン -- 貢献プロセス - -このファイルは今後、プロジェクトの変更に合わせて更新していくことをおすすめします。 - From 410189a47220205bedf4ebc0e6665bd97bdd82c2 Mon Sep 17 00:00:00 2001 From: dendencat Date: Sun, 19 Jul 2026 00:18:32 +0900 Subject: [PATCH 5/6] Address independent review findings - Fold the legacy 'test' in sys.argv override into IS_TESTING and delete it; it silently forced manage.py test back onto SQLite, defeating TEST_DB_ENGINE. The MD5 password hasher moves into the shared testing block (pytest now benefits too: suite runtime dropped 65s -> 6s). - Gate the build/publish job on the smoke job, not just unit tests. - Strengthen CSRF tests: prove login works with the form token and guard the editor's X-CSRFToken header against template regressions. Co-Authored-By: Claude Fable 5 --- .github/workflows/docker-publish.yml | 2 +- techblog_cms/settings.py | 15 ++++----------- techblog_cms/tests/test_csrf.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 8def130..24b059d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -129,7 +129,7 @@ jobs: run: docker compose logs django db redis build: - needs: test + needs: [test, smoke] runs-on: ubuntu-latest steps: diff --git a/techblog_cms/settings.py b/techblog_cms/settings.py index da94b6c..9a4ec72 100644 --- a/techblog_cms/settings.py +++ b/techblog_cms/settings.py @@ -65,7 +65,7 @@ # Database # Detect testing mode either via explicit env var or when running under pytest -IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or any( +IS_TESTING = os.environ.get('TESTING') == 'True' or 'PYTEST_CURRENT_TEST' in os.environ or 'test' in sys.argv or any( x.endswith('pytest') for x in sys.modules.keys() ) logger.debug("IS_TESTING: %s", IS_TESTING) @@ -98,6 +98,9 @@ } } SESSION_ENGINE = 'django.contrib.sessions.backends.cache' + PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', + ] DEBUG = True APPEND_SLASH = False else: @@ -333,13 +336,3 @@ EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@techblog.com') ADMINS = [('Admin', config('ADMIN_EMAIL', default='admin@techblog.com'))] - -# Testing configuration -if 'test' in sys.argv: - DATABASES['default'] = { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } - PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.MD5PasswordHasher', - ] diff --git a/techblog_cms/tests/test_csrf.py b/techblog_cms/tests/test_csrf.py index 0c56027..73571ad 100644 --- a/techblog_cms/tests/test_csrf.py +++ b/techblog_cms/tests/test_csrf.py @@ -17,6 +17,21 @@ def test_login_post_without_token_is_rejected(self): ) self.assertEqual(response.status_code, 403) + def test_login_with_token_from_form_succeeds(self): + login_page = self.csrf_client.get(reverse("login")) + self.assertContains(login_page, "csrfmiddlewaretoken") + token = self.csrf_client.cookies["csrftoken"].value + + response = self.csrf_client.post( + reverse("login"), + { + "username": "editor", + "password": "pass1234", + "csrfmiddlewaretoken": token, + }, + ) + self.assertRedirects(response, reverse("dashboard")) + def test_preview_post_without_token_is_rejected(self): self.csrf_client.force_login(self.user) response = self.csrf_client.post(reverse("preview_markdown"), {"text": "# hi"}) @@ -26,6 +41,9 @@ def test_preview_post_with_token_succeeds(self): self.csrf_client.force_login(self.user) editor_page = self.csrf_client.get(reverse("article_new")) self.assertEqual(editor_page.status_code, 200) + self.assertContains(editor_page, "csrfmiddlewaretoken") + # Regression guard: the preview fetch must keep sending the header. + self.assertContains(editor_page, "X-CSRFToken") token = self.csrf_client.cookies["csrftoken"].value response = self.csrf_client.post( From aac1ca8f3bf4d6ed626be847173051518ec10ee3 Mon Sep 17 00:00:00 2001 From: dendencat Date: Sun, 19 Jul 2026 00:24:03 +0900 Subject: [PATCH 6/6] Fix entrypoint crash loop on fresh volumes (caught by the new smoke job) On a fresh checkout the static/media named volumes initialize root-owned, and the entrypoint - running as appuser under set -e - dies on chown, restarting forever. Pre-create /app/static and /app/media owned by appuser in the image so first-use volumes inherit that ownership, and make the entrypoint chowns best-effort like the chmods next to them. Long-lived local volumes masked this; the CI smoke job caught it on its first run. Co-Authored-By: Claude Fable 5 --- Dockerfile.django | 5 +++-- docker/entrypoint.sh | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Dockerfile.django b/Dockerfile.django index a3622b5..cf58f1c 100644 --- a/Dockerfile.django +++ b/Dockerfile.django @@ -42,8 +42,9 @@ COPY . . # Create a non-root user to run the application. RUN groupadd -r appgroup && useradd -r -g appgroup appuser -RUN chown -R appuser:appgroup /app -RUN mkdir -p /app/logs && chown -R appuser:appgroup /app/logs +# static/media/logs must exist owned by appuser so fresh named volumes +# mounted there inherit that ownership on first use. +RUN mkdir -p /app/static /app/media /app/logs && chown -R appuser:appgroup /app # Entrypoint to run migrations and collectstatic RUN chmod +x /app/docker/entrypoint.sh diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index f2117d8..57f4f2a 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,16 +10,18 @@ if [ ! -f /app/logs/error.log ]; then fi # ログディレクトリの権限設定 +# chown は root 実行時のみ成功する。非 root(compose の user: appuser)では +# ボリューム所有権はイメージ側の設定(Dockerfile)で継承されるため、失敗しても致命的でない。 echo "Setting up log directory..." mkdir -p /app/logs -chown -R appuser:appgroup /app/logs +chown -R appuser:appgroup /app/logs || true chmod 755 /app/logs || true chmod 664 /app/logs/access.log || true # 静的ファイルディレクトリの作成と権限設定(collectstatic 前に実施) echo "Preparing static directory..." mkdir -p /app/static -chown -R appuser:appgroup /app/static +chown -R appuser:appgroup /app/static || true chmod -R 755 /app/static || true # -------------------------------------------