diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..036c7ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitea +.env +.venv +__pycache__ +*.py[cod] +README.md +start.sh +start.ps1 diff --git a/.gitea/workflows/container-image.yml b/.gitea/workflows/container-image.yml new file mode 100644 index 0000000..534509d --- /dev/null +++ b/.gitea/workflows/container-image.yml @@ -0,0 +1,54 @@ +name: Validate and publish container image + +on: + pull_request: + branches: + - main + push: + branches: + - main + +env: + REGISTRY: harbor.mukan.0am.jp + IMAGE_NAME: harbor.mukan.0am.jp/services/email-to-discord + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build test stage + run: | + docker build \ + --target test \ + --tag "${IMAGE_NAME}:test-${{ gitea.sha }}" \ + . + + - name: Build runtime image + run: | + docker build \ + --target runtime \ + --tag "${IMAGE_NAME}:${{ gitea.sha }}" \ + --tag "${IMAGE_NAME}:latest" \ + . + + - name: Log in to Harbor + if: gitea.event_name == 'push' + env: + HARBOR_USERNAME: ${{ secrets.HARBOR_USERNAME }} + HARBOR_PASSWORD: ${{ secrets.HARBOR_PASSWORD }} + run: | + printf '%s' "${HARBOR_PASSWORD}" | docker login "${REGISTRY}" \ + --username "${HARBOR_USERNAME}" \ + --password-stdin + + - name: Push runtime image + if: gitea.event_name == 'push' + run: | + docker push "${IMAGE_NAME}:${{ gitea.sha }}" + docker push "${IMAGE_NAME}:latest" diff --git a/Dockerfile b/Dockerfile index 47ea1d5..cda5512 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.11-slim AS base # 作業ディレクトリを設定 WORKDIR /app @@ -15,14 +15,23 @@ RUN pip install --no-cache-dir -r requirements.txt # アプリケーションファイルをコピー COPY app.py . +# テスト用ステージ(CIではこのステージまでビルドして単体テストを実行) +FROM base AS test +COPY docker-compose.yml docker-compose.local.yml ./ +COPY tests ./tests +RUN python -m unittest discover -s tests -v + +# 実行用ステージ +FROM base AS runtime + # 非rootユーザーを作成 -RUN useradd --create-home --shell /bin/bash appuser -RUN chown -R appuser:appuser /app +RUN useradd --create-home --shell /bin/bash appuser \ + && chown -R appuser:appuser /app USER appuser # ヘルスチェックを追加 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD python -c "import sys; sys.exit(0)" + CMD python -c "from pathlib import Path; import sys; sys.exit(0 if b'app.py' in Path('/proc/1/cmdline').read_bytes() else 1)" # アプリケーションを実行 -CMD ["python", "app.py"] \ No newline at end of file +CMD ["python", "app.py"] diff --git a/README.md b/README.md index 2a21d8b..25f599f 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,46 @@ cp .env.example .env # .envファイルを編集して実際の値を設定 ``` -## 🐳 Docker Composeでの実行 +## 🚢 Agents `container_tools` へのデプロイ + +`container_tools` はイメージをビルドせず、レジストリからpullして起動します。このリポジトリでは、`main` へのpush時にGitea Actionsが次のイメージをHarborへ公開します。 + +```text +harbor.mukan.0am.jp/services/email-to-discord: +harbor.mukan.0am.jp/services/email-to-discord:latest +``` + +### 1. Gitea Actions Secretsの設定 + +リポジトリのActions Secretsへ次の2項目を登録します。実際の値をリポジトリやComposeへコミットしないでください。 + +- `HARBOR_USERNAME` +- `HARBOR_PASSWORD` + +Pull Requestではテストステージと実行イメージのビルドだけを行い、`main` へマージされたときだけHarborへpushします。 + +### 2. デプロイ用Composeの準備 + +通常の [`docker-compose.yml`](docker-compose.yml) は `container_tools` 用テンプレートです。次の値を実際のデプロイ情報へ置き換えます。 + +- イメージタグの `latest`(可能ならGitea Actionsが公開したコミットSHAへ変更) +- `REPLACE_WITH_EMAIL_USER` +- `REPLACE_WITH_EMAIL_PASSWORD` +- `REPLACE_WITH_WEBHOOK_URL` +- `REPLACE_WITH_WEBHOOK_TOKEN` +- `REPLACE_WITH_DISCORD_CHANNEL_ID` + +Discordへの直接通知も併用する場合だけ、空の `DISCORD_WEBHOOK_URL` に実際のURLを設定します。`WEBHOOK_SECRET` は設定しません。 + +Agentsの実行時情報にある `WEBHOOK_URL` が `http://127.0.0.1:/webhook` の場合、コンテナ内の `127.0.0.1` はサテライト自身を指すため、`http://host.docker.internal:/webhook` に置き換えてください。ComposeにはLinuxからDockerホストへ到達するための `host-gateway` を設定済みです。 + +### 3. デプロイ + +置換後のCompose全文を、安定した `project_name` とともに `container_tools` へ渡します。デプロイ用Composeには、`container_tools` が禁止する `build`、`container_name`、`env_file`、host bind mountを含めていません。 + +## 🐳 ローカルDocker Composeでの実行 + +ローカルでソースからビルドする場合は、デプロイ用とは別の `docker-compose.local.yml` を使用します。 ### 1. `.env` の設定 @@ -101,13 +140,13 @@ DISCORD_CHANNEL_ID=123456789012345678 ```bash # コンテナをビルドして起動 -docker-compose up -d +docker compose -f docker-compose.local.yml up --build -d # ログの確認 -docker-compose logs -f +docker compose -f docker-compose.local.yml logs -f # コンテナの停止 -docker-compose down +docker compose -f docker-compose.local.yml down ``` ## 🚀 Linux用シェルスクリプトでの実行 diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..522892f --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,22 @@ +services: + email-to-discord: + build: + context: . + target: runtime + container_name: email-to-discord-monitor + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + env_file: + - .env + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M diff --git a/docker-compose.yml b/docker-compose.yml index 4ebb02d..fcb8f1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,27 @@ -version: '3.8' - services: email-to-discord: - build: . - container_name: email-to-discord-monitor + image: harbor.mukan.0am.jp/services/email-to-discord:latest restart: unless-stopped - env_file: - - .env - - # ログの設定 + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + IMAP_SERVER: imap.gmail.com + IMAP_PORT: "993" + USE_SSL: "true" + MAILBOX: INBOX + EMAIL_USER: REPLACE_WITH_EMAIL_USER + EMAIL_PASSWORD: REPLACE_WITH_EMAIL_PASSWORD + DISCORD_WEBHOOK_URL: "" + WEBHOOK_URL: REPLACE_WITH_WEBHOOK_URL + WEBHOOK_TOKEN: REPLACE_WITH_WEBHOOK_TOKEN + DISCORD_CHANNEL_ID: "REPLACE_WITH_DISCORD_CHANNEL_ID" + WEBHOOK_SOURCE: email-monitor + CHECK_INTERVAL: "60" logging: driver: "json-file" options: max-size: "10m" max-file: "3" - - # リソース制限 deploy: resources: limits: diff --git a/start.ps1 b/start.ps1 index c8c676b..cdeb20c 100644 --- a/start.ps1 +++ b/start.ps1 @@ -225,7 +225,7 @@ function Invoke-Cleanup { # docker-compose で起動 function Start-Compose { Log-Info "docker-compose でサービスを起動しています..." - docker-compose up -d + docker-compose -f docker-compose.local.yml up --build -d if ($LASTEXITCODE -eq 0) { Log-Info "サービスが起動しました" Log-Blue "ログを確認するには: .\start.ps1 compose-logs" @@ -238,7 +238,7 @@ function Start-Compose { # docker-compose で停止 function Stop-Compose { Log-Info "docker-compose でサービスを停止しています..." - docker-compose down + docker-compose -f docker-compose.local.yml down Log-Info "サービスを停止しました" } @@ -322,7 +322,7 @@ switch ($Action.ToLower()) { Stop-Compose } "compose-logs" { - docker-compose logs -f + docker-compose -f docker-compose.local.yml logs -f } "python" { Start-Python diff --git a/start.sh b/start.sh index 59badf7..79a6d7e 100644 --- a/start.sh +++ b/start.sh @@ -230,9 +230,9 @@ compose_up() { log_info "docker-compose でサービスを起動しています..." if command -v docker-compose &> /dev/null; then - docker-compose up -d + docker-compose -f docker-compose.local.yml up --build -d else - docker compose up -d + docker compose -f docker-compose.local.yml up --build -d fi log_info "サービスが起動しました" @@ -244,9 +244,9 @@ compose_down() { log_info "docker-compose でサービスを停止しています..." if command -v docker-compose &> /dev/null; then - docker-compose down + docker-compose -f docker-compose.local.yml down else - docker compose down + docker compose -f docker-compose.local.yml down fi log_info "サービスを停止しました" @@ -327,9 +327,9 @@ main() { ;; "compose-logs") if command -v docker-compose &> /dev/null; then - docker-compose logs -f + docker-compose -f docker-compose.local.yml logs -f else - docker compose logs -f + docker compose -f docker-compose.local.yml logs -f fi ;; "python") diff --git a/tests/test_deployment.py b/tests/test_deployment.py new file mode 100644 index 0000000..c1674ca --- /dev/null +++ b/tests/test_deployment.py @@ -0,0 +1,35 @@ +import re +import unittest +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +class ContainerToolsDeploymentTests(unittest.TestCase): + def test_deployment_compose_uses_prebuilt_image_only(self): + compose = (REPOSITORY_ROOT / "docker-compose.yml").read_text(encoding="utf-8") + + self.assertIn( + "image: harbor.mukan.0am.jp/services/email-to-discord:latest", + compose, + ) + self.assertIn("host.docker.internal:host-gateway", compose) + for forbidden_key in ("build", "container_name", "env_file"): + self.assertIsNone( + re.search(rf"^\s+{forbidden_key}:", compose, flags=re.MULTILINE), + f"container_toolsで禁止されている'{forbidden_key}'が含まれています", + ) + + def test_local_compose_keeps_source_build_support(self): + compose = (REPOSITORY_ROOT / "docker-compose.local.yml").read_text( + encoding="utf-8" + ) + + self.assertRegex(compose, r"(?m)^\s+build:") + self.assertIn("target: runtime", compose) + self.assertIn("host.docker.internal:host-gateway", compose) + + +if __name__ == "__main__": + unittest.main()