From 1bd0fec23a80010be5307063a22e4e6143ed81f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 08:10:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Alpine=20=E3=83=99=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=81=AE=20Claude=20Code=20=E3=83=AA=E3=83=A2=E3=83=BC?= =?UTF-8?q?=E3=83=88=E5=AE=9F=E8=A1=8C=20Helm=20=E3=83=81=E3=83=A3?= =?UTF-8?q?=E3=83=BC=E3=83=88=E5=88=9D=E7=89=88=20(v2.1.259)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gateway(nginx) + manager(python 標準ライブラリ) + セッションごとの使い捨て Pod + reaper CronJob の構成。ブラウザ(PC/スマホ)からアクセスし、セッション単位で 独立した Alpine コンテナに Git リポジトリ(GitHub/Gitea など)をチェックアウトして 編集・検証・プッシュする。無操作が続いたセッションは Pod ごと自動破棄。 - セキュリティ: セッション NS 分離 / restricted PodSecurity / RBAC はセッション NS の pods,services のみの Role / セッション Pod は SA トークン非マウント・非 root・ readOnlyRootFilesystem・drop ALL caps・seccomp / NetworkPolicy で egress を DNS+HTTPS+SSH に限定 / Git ホスト allow-list / ResourceQuota+LimitRange - ライフサイクル: idleTimeout(Pod 内ウォッチドッグ + manager 回収) / maxLifetime (activeDeadlineSeconds) / maxSessions / 予備の孤児掃除 CronJob - 認証: token(既定) / basic / forward(外部SSO) / none。WebSocket は Cookie トークン - Claude 認証: ANTHROPIC_API_KEY または CLAUDE_CODE_OAUTH_TOKEN - CI: helm-release.yaml(未リリース版のみ tag+release+publish) / image-update-and-release.yaml(週次 npm 追従) / session-image.yaml(multi-arch build) - files/ 実体を .Files.Get で ConfigMap 化。helm lint / template 検証済み --- .gitattributes | 4 + .gitea/workflows/helm-release.yaml | 131 ++++ .../workflows/image-update-and-release.yaml | 188 +++++ .gitea/workflows/session-image.yaml | 76 ++ .gitignore | 21 + CHANGELOG-2.1.259.md | 41 ++ CLAUDE.md | 92 +++ Chart.yaml | 32 + README.md | 370 +++++++++- examples/gitea-and-github.values.yaml | 69 ++ examples/minimal.values.yaml | 26 + examples/oidc-forward-auth.values.yaml | 41 ++ files/Dockerfile.session | 37 + files/gateway-entrypoint.sh | 68 ++ files/gateway.conf.template | 100 +++ files/manager.py | 685 ++++++++++++++++++ files/session-entrypoint.sh | 230 ++++++ templates/NOTES.txt | 57 ++ templates/_helpers.tpl | 112 +++ templates/configmap-gateway.yaml | 12 + templates/configmap-manager.yaml | 10 + templates/configmap-session.yaml | 10 + templates/cronjob-reaper.yaml | 71 ++ templates/deployment.yaml | 255 +++++++ templates/ingress.yaml | 40 + templates/limitrange.yaml | 19 + templates/namespace-sessions.yaml | 19 + templates/networkpolicy-manager.yaml | 51 ++ templates/networkpolicy-session.yaml | 75 ++ templates/poddisruptionbudget.yaml | 15 + templates/rbac.yaml | 40 + templates/resourcequota.yaml | 22 + templates/secret-anthropic.yaml | 18 + templates/secret-auth.yaml | 32 + templates/secret-git.yaml | 30 + templates/service.yaml | 18 + templates/serviceaccount.yaml | 22 + values.schema.json | 269 +++++++ values.yaml | 338 +++++++++ 39 files changed, 3745 insertions(+), 1 deletion(-) create mode 100644 .gitattributes create mode 100644 .gitea/workflows/helm-release.yaml create mode 100644 .gitea/workflows/image-update-and-release.yaml create mode 100644 .gitea/workflows/session-image.yaml create mode 100644 .gitignore create mode 100644 CHANGELOG-2.1.259.md create mode 100644 CLAUDE.md create mode 100644 Chart.yaml create mode 100644 examples/gitea-and-github.values.yaml create mode 100644 examples/minimal.values.yaml create mode 100644 examples/oidc-forward-auth.values.yaml create mode 100644 files/Dockerfile.session create mode 100644 files/gateway-entrypoint.sh create mode 100644 files/gateway.conf.template create mode 100644 files/manager.py create mode 100644 files/session-entrypoint.sh create mode 100644 templates/NOTES.txt create mode 100644 templates/_helpers.tpl create mode 100644 templates/configmap-gateway.yaml create mode 100644 templates/configmap-manager.yaml create mode 100644 templates/configmap-session.yaml create mode 100644 templates/cronjob-reaper.yaml create mode 100644 templates/deployment.yaml create mode 100644 templates/ingress.yaml create mode 100644 templates/limitrange.yaml create mode 100644 templates/namespace-sessions.yaml create mode 100644 templates/networkpolicy-manager.yaml create mode 100644 templates/networkpolicy-session.yaml create mode 100644 templates/poddisruptionbudget.yaml create mode 100644 templates/rbac.yaml create mode 100644 templates/resourcequota.yaml create mode 100644 templates/secret-anthropic.yaml create mode 100644 templates/secret-auth.yaml create mode 100644 templates/secret-git.yaml create mode 100644 templates/service.yaml create mode 100644 templates/serviceaccount.yaml create mode 100644 values.schema.json create mode 100644 values.yaml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..31aa78a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Helm チャートのテキストは常に LF(CRLF はブロックスカラー等で問題を起こしうる) +* text=auto eol=lf +*.png binary +*.tgz binary diff --git a/.gitea/workflows/helm-release.yaml b/.gitea/workflows/helm-release.yaml new file mode 100644 index 0000000..8caed9a --- /dev/null +++ b/.gitea/workflows/helm-release.yaml @@ -0,0 +1,131 @@ +# .gitea/workflows/helm-release.yaml +# プロジェクトへの変更(main への push)時に実行。 +# Chart.yaml の version が「まだリリースされていない」場合のみ、 +# タグ作成 → Gitea リリース → Helm チャート publish を行う。 +# 既にリリース済みのバージョンなら lint のみで終了する(再 publish しない)。 + +name: Helm Chart Release + +on: + push: + branches: + - main + - master + workflow_dispatch: + +env: + REGISTRY_URL: https://git.cafepieters.com + OWNER: helmchart + +jobs: + release-chart: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Helm + uses: azure/setup-helm@v3 + with: + version: 'v3.16.4' + + - name: Ensure jq + run: command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq) + + - name: Get chart info + id: info + run: | + set -e + CHART_NAME=$(grep '^name:' Chart.yaml | awk '{print $2}') + CHART_VERSION=$(grep '^version:' Chart.yaml | awk '{print $2}' | tr -d '"') + echo "name=${CHART_NAME}" >> $GITHUB_OUTPUT + echo "version=${CHART_VERSION}" >> $GITHUB_OUTPUT + echo "Chart: ${CHART_NAME} ${CHART_VERSION}" + + - name: Lint + run: helm lint . + + - name: Check whether this version is already released + id: check + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -e + V="v${{ steps.info.outputs.version }}" + # 1) Gitea リリースの有無 + REL_ID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${V}" | jq -r '.id // empty') + # 2) Package Registry 上の有無 + PKG_HTTP=$(curl -s -o /dev/null -w '%{http_code}' \ + -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \ + "${REGISTRY_URL}/api/packages/${OWNER}/helm/${{ steps.info.outputs.name }}/${{ steps.info.outputs.version }}") + + if [ -n "$REL_ID" ] || [ "$PKG_HTTP" = "200" ]; then + echo "release_needed=false" >> $GITHUB_OUTPUT + echo "既に ${V} はリリース済み(release_id=${REL_ID:-none}, pkg_http=${PKG_HTTP})。publish をスキップします。" + else + echo "release_needed=true" >> $GITHUB_OUTPUT + echo "${V} は未リリース。リリースを実行します。" + fi + + - name: Package + if: steps.check.outputs.release_needed == 'true' + run: | + set -e + helm package . + echo "CHART_FILE=$(ls ${{ steps.info.outputs.name }}-*.tgz)" >> $GITHUB_ENV + + - name: Create Git tag + if: steps.check.outputs.release_needed == 'true' + run: | + set -e + git config user.name "Claude" + git config user.email "claude@cafepieters.com" + V="v${{ steps.info.outputs.version }}" + if git rev-parse "$V" >/dev/null 2>&1; then + echo "tag $V exists, skip" + else + git tag -a "$V" -m "Release $V" + git push origin "$V" + fi + + - name: Create Gitea release + if: steps.check.outputs.release_needed == 'true' + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -e + V="v${{ steps.info.outputs.version }}" + BODY="${{ steps.info.outputs.name }} Helm Chart ${V}" + RID=$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${V}\",\"name\":\"${V}\",\"body\":\"${BODY}\"}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases" | jq -r '.id // empty') + if [ -z "$RID" ]; then + RID=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${V}" | jq -r '.id // empty') + fi + curl --fail-with-body -X POST -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/gzip" --data-binary "@${CHART_FILE}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/${RID}/assets?name=${CHART_FILE}" + echo "Gitea release ${V} 作成完了" + + - name: Publish to Gitea Package Registry + if: steps.check.outputs.release_needed == 'true' + run: | + set -e + curl --fail-with-body -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \ + -X POST --upload-file "${CHART_FILE}" \ + "${REGISTRY_URL}/api/packages/${OWNER}/helm/api/charts" + echo "✅ ${CHART_FILE} を publish しました" + + - name: Summary + if: always() + run: | + echo "================================" + echo "Chart : ${{ steps.info.outputs.name }} ${{ steps.info.outputs.version }}" + echo "Released : ${{ steps.check.outputs.release_needed }}" + echo "================================" + echo "helm repo add cafepieters ${REGISTRY_URL}/api/packages/${OWNER}/helm" + echo "helm repo update && helm install my-${{ steps.info.outputs.name }} cafepieters/${{ steps.info.outputs.name }}" diff --git a/.gitea/workflows/image-update-and-release.yaml b/.gitea/workflows/image-update-and-release.yaml new file mode 100644 index 0000000..5278e62 --- /dev/null +++ b/.gitea/workflows/image-update-and-release.yaml @@ -0,0 +1,188 @@ +name: Update Versions and Release Helm Chart + +# 毎週月曜 3:00 AM JST(日曜 18:00 UTC)に実行。 +# - 主アプリ = Claude Code CLI(npm: @anthropic-ai/claude-code)の最新版を確認 +# - base image = gateway(nginx) / manager(python) の最新 Alpine タグを確認 +# - 主アプリが更新: values.yaml / Chart.yaml / README 履歴を更新しコミット → タグ + Gitea リリース + チャート publish +# - base image のみ更新: values.yaml を更新してコミットのみ(リリースなし) +# +# セッションイメージのビルドは session-image.yaml が担当する(このワークフローが +# Chart.yaml を push した結果として発火する)。 + +on: + schedule: + - cron: '0 18 * * 0' + workflow_dispatch: + +env: + REGISTRY_URL: https://git.cafepieters.com + OWNER: helmchart + +jobs: + update-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Install Helm + uses: azure/setup-helm@v3 + with: + version: 'v3.16.4' + + - name: Ensure jq + run: command -v jq >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y -qq jq) + + - name: Check Claude Code (npm) version + id: cc + run: | + set -e + CURRENT=$(grep '^appVersion:' Chart.yaml | sed 's/.*"\(.*\)".*/\1/') + LATEST=$(curl -s --retry 3 --retry-delay 5 \ + "https://registry.npmjs.org/@anthropic-ai/claude-code/latest" | jq -r '.version // empty') + echo "Current: $CURRENT / npm latest: ${LATEST:-not found}" + + if [ -n "$LATEST" ]; then + HIGHER=$(printf '%s\n%s' "$CURRENT" "$LATEST" | sort -V | tail -1) + if [ "$HIGHER" != "$LATEST" ] && [ "$CURRENT" != "$LATEST" ]; then + echo "Warning: npm latest ($LATEST) < current ($CURRENT). Skipping." + LATEST="$CURRENT" + fi + else + LATEST="$CURRENT" + fi + echo "current=$CURRENT" >> $GITHUB_OUTPUT + echo "latest=$LATEST" >> $GITHUB_OUTPUT + + - name: Check base image (nginx / python) tags + id: base + run: | + set -e + NGINX_CUR=$(grep -A3 'gateway:' values.yaml | grep 'tag:' | head -1 | sed 's/.*"\(.*\)".*/\1/') + PY_CUR=$(grep -A3 'manager:' values.yaml | grep 'tag:' | head -1 | sed 's/.*"\(.*\)".*/\1/') + + NGINX_LATEST=$(curl -s --retry 3 "https://registry.hub.docker.com/v2/repositories/nginxinc/nginx-unprivileged/tags?page_size=100" \ + | jq -r '.results[].name' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+-alpine$' | sort -V | tail -1) + PY_LATEST=$(curl -s --retry 3 "https://registry.hub.docker.com/v2/repositories/library/python/tags?page_size=100&name=alpine" \ + | jq -r '.results[].name' | grep -E '^3\.[0-9]+-alpine$' | sort -V | tail -1) + + [ -n "$NGINX_LATEST" ] || NGINX_LATEST="$NGINX_CUR" + [ -n "$PY_LATEST" ] || PY_LATEST="$PY_CUR" + echo "nginx: $NGINX_CUR -> $NGINX_LATEST ; python: $PY_CUR -> $PY_LATEST" + echo "nginx_cur=$NGINX_CUR" >> $GITHUB_OUTPUT + echo "nginx_latest=$NGINX_LATEST" >> $GITHUB_OUTPUT + echo "py_cur=$PY_CUR" >> $GITHUB_OUTPUT + echo "py_latest=$PY_LATEST" >> $GITHUB_OUTPUT + + - name: Decide + id: decide + run: | + set -e + CC=false; BASE=false + [ "${{ steps.cc.outputs.current }}" != "${{ steps.cc.outputs.latest }}" ] && CC=true + [ "${{ steps.base.outputs.nginx_cur }}" != "${{ steps.base.outputs.nginx_latest }}" ] && BASE=true + [ "${{ steps.base.outputs.py_cur }}" != "${{ steps.base.outputs.py_latest }}" ] && BASE=true + echo "cc=$CC" >> $GITHUB_OUTPUT + echo "base=$BASE" >> $GITHUB_OUTPUT + echo "update_needed=$([ "$CC" = true ] || [ "$BASE" = true ] && echo true || echo false)" >> $GITHUB_OUTPUT + + - name: Update values.yaml (image tags) + if: steps.decide.outputs.update_needed == 'true' + run: | + set -e + sed -i "s|tag: \"${{ steps.base.outputs.nginx_cur }}\"|tag: \"${{ steps.base.outputs.nginx_latest }}\"|g" values.yaml + sed -i "s|tag: \"${{ steps.base.outputs.py_cur }}\"|tag: \"${{ steps.base.outputs.py_latest }}\"|g" values.yaml + if [ "${{ steps.decide.outputs.cc }}" = "true" ]; then + sed -i "s|tag: \"${{ steps.cc.outputs.current }}\"|tag: \"${{ steps.cc.outputs.latest }}\"|g" values.yaml + fi + git diff --stat values.yaml || true + + - name: Update Chart.yaml (version == Claude Code version) + if: steps.decide.outputs.cc == 'true' + run: | + set -e + NEW="${{ steps.cc.outputs.latest }}" + sed -i "s/^version: .*/version: ${NEW}/" Chart.yaml + sed -i "s/^appVersion: .*/appVersion: \"${NEW}\"/" Chart.yaml + cat Chart.yaml + + - name: Update README version history + if: steps.decide.outputs.cc == 'true' + run: | + set -e + NEW="${{ steps.cc.outputs.latest }}" + sed -i "/^| バージョン | Claude Code | 変更内容 |/{ + n + a\| ${NEW} | ${NEW} | 自動更新 | + }" README.md + + - name: Lint + if: steps.decide.outputs.update_needed == 'true' + run: helm lint . + + - name: Commit + if: steps.decide.outputs.update_needed == 'true' + run: | + set -e + git config user.name "Claude" + git config user.email "claude@cafepieters.com" + if [ "${{ steps.decide.outputs.cc }}" = "true" ]; then + git add values.yaml Chart.yaml README.md + git commit -m "chore: update Claude Code to ${{ steps.cc.outputs.latest }} (nginx ${{ steps.base.outputs.nginx_latest }}, python ${{ steps.base.outputs.py_latest }})" + else + git add values.yaml + git commit -m "chore: bump base images — nginx ${{ steps.base.outputs.nginx_latest }}, python ${{ steps.base.outputs.py_latest }} (no release)" + fi + git push origin "${GITHUB_REF_NAME:-main}" + + - name: Package + if: steps.decide.outputs.cc == 'true' + run: | + set -e + helm package . + echo "CHART_FILE=$(ls claude-code-*.tgz)" >> $GITHUB_ENV + + - name: Git tag + if: steps.decide.outputs.cc == 'true' + run: | + set -e + V="v${{ steps.cc.outputs.latest }}" + git rev-parse "$V" >/dev/null 2>&1 && { echo "tag $V exists"; exit 0; } + git tag -a "$V" -m "Release $V" + git push origin "$V" + + - name: Gitea Release + if: steps.decide.outputs.cc == 'true' + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -e + NEW="${{ steps.cc.outputs.latest }}"; V="v${NEW}" + if curl -s -H "Authorization: token ${GITEA_TOKEN}" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${V}" | jq -e '.id' >/dev/null 2>&1; then + echo "release ${V} exists, skip" + else + RID=$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${V}\",\"name\":\"${V}\",\"body\":\"claude-code Helm Chart ${V} — Claude Code CLI ${NEW}\"}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases" | jq -r '.id') + curl --fail-with-body -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/gzip" \ + --data-binary "@${CHART_FILE}" \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/${RID}/assets?name=${CHART_FILE}" + fi + + - name: Publish chart to Gitea Package Registry + if: steps.decide.outputs.cc == 'true' + run: | + set -e + curl --fail-with-body -u "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" \ + -X POST --upload-file "${CHART_FILE}" \ + "${REGISTRY_URL}/api/packages/${OWNER}/helm/api/charts" + + - name: Summary + if: always() + run: | + echo "Claude Code : ${{ steps.cc.outputs.current }} -> ${{ steps.cc.outputs.latest }} (updated: ${{ steps.decide.outputs.cc }})" + echo "nginx : ${{ steps.base.outputs.nginx_cur }} -> ${{ steps.base.outputs.nginx_latest }}" + echo "python : ${{ steps.base.outputs.py_cur }} -> ${{ steps.base.outputs.py_latest }}" + echo "release : ${{ steps.decide.outputs.cc }}" diff --git a/.gitea/workflows/session-image.yaml b/.gitea/workflows/session-image.yaml new file mode 100644 index 0000000..87af984 --- /dev/null +++ b/.gitea/workflows/session-image.yaml @@ -0,0 +1,76 @@ +name: Build Session Image + +# セッション用の事前ビルドイメージ(git + openssh + ttyd + node + @anthropic-ai/claude-code)を +# linux/amd64 + linux/arm64 でビルドし、Gitea Container Registry へ push する。 +# +# トリガー: +# - files/Dockerfile.session / files/session-entrypoint.sh / Chart.yaml の変更 push +# (image-update-and-release.yaml が Chart.yaml を更新した後にも発火する) +# - 手動実行(tag を任意指定可能) +# +# タグ: Chart.yaml の appVersion(= Claude Code CLI バージョン) と latest。 + +on: + push: + branches: [main, master] + paths: + - 'files/Dockerfile.session' + - 'files/session-entrypoint.sh' + - 'Chart.yaml' + - '.gitea/workflows/session-image.yaml' + workflow_dispatch: + inputs: + tag: + description: 'イメージタグ(未指定なら Chart.yaml の appVersion)' + required: false + +env: + REGISTRY_HOST: git.cafepieters.com + IMAGE: git.cafepieters.com/helmchart/claude-code/session + +jobs: + build-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Resolve tag + id: t + run: | + set -e + TAG="${{ github.event.inputs.tag }}" + [ -n "$TAG" ] || TAG=$(grep '^appVersion:' Chart.yaml | sed 's/.*"\(.*\)".*/\1/') + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "Session image tag: $TAG" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY_HOST }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build & push (multi-arch) + uses: docker/build-push-action@v6 + with: + context: . + file: files/Dockerfile.session + platforms: linux/amd64,linux/arm64 + build-args: | + CLAUDE_CODE_VERSION=${{ steps.t.outputs.tag }} + tags: | + ${{ env.IMAGE }}:${{ steps.t.outputs.tag }} + ${{ env.IMAGE }}:latest + push: true + provenance: false + + - name: Summary + run: | + echo "pushed ${IMAGE}:${{ steps.t.outputs.tag }} (+ latest) for linux/amd64,linux/arm64" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a00eb0f --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Helm パッケージ成果物 +*.tgz +charts/ + +# 秘密情報を含む values ファイル(絶対にコミットしない) +secrets.values.yaml +secrets.*.yaml +*.secret.yaml +*.local.yaml +values.local.yaml + +# SSH 鍵・トークンの一時ファイル +*.pem +id_* +!examples/** +*.dec +*.dec.yaml + +# OS / エディタ +.DS_Store +*.swp diff --git a/CHANGELOG-2.1.259.md b/CHANGELOG-2.1.259.md new file mode 100644 index 0000000..332dab0 --- /dev/null +++ b/CHANGELOG-2.1.259.md @@ -0,0 +1,41 @@ +# CHANGELOG 2.1.259 — 初版 + +claude-code Helm チャートの新規作成。 + +## 追加 + +- **制御プレーン(Deployment)** + - `gateway`(nginx-unprivileged / Alpine): 単一入口。`auth.mode` に応じて + token / basic / forward / none の認証、`/s//` のセッションへの WebSocket + リバースプロキシ、`auth_request` によるアクセス時刻の更新。 + - `manager`(python / Alpine, 標準ライブラリのみ): セッション CRUD API、 + モバイル対応の Web UI(ビルド不要)、アイドル / 最大寿命の回収ループ。 + in-cluster ServiceAccount で Kubernetes API を直接操作。 +- **セッション(使い捨て Pod)** + - `files/session-entrypoint.sh`: git 認証(SSH 鍵 / HTTPS トークン)、許可済み + リポジトリの clone、`claude` 用意、`ttyd` 起動、アイドルウォッチドッグ。 + - `claude-verify` / `claude-push`(保護ブランチ・`--force` 禁止の安全 push)。 + - `files/Dockerfile.session`: git + ttyd + claude-code 同梱の事前ビルドイメージ + (CI で multi-arch publish、コールドスタート短縮)。 +- **ライフサイクル** + - `session.idleTimeoutSeconds` 無通信で Pod 自動破棄(Pod 内ウォッチドッグ + manager 回収)。 + - `session.maxLifetimeSeconds` を `activeDeadlineSeconds` として強制。 + - `session.maxSessions` で同時セッション数を制限。 + - 予備の孤児掃除 CronJob(`manager --reap-once`)。 +- **セキュリティ** + - セッション NS を制御プレーンから分離(`sessionsNamespace`, PodSecurity `restricted`)。 + - manager RBAC はセッション NS の `pods` / `services` に限定した Role のみ。 + - セッション Pod は SA トークン非マウント(API サーバー到達不可)、非 root / + `readOnlyRootFilesystem` / drop ALL caps / seccomp `RuntimeDefault`。 + - NetworkPolicy: セッションの ingress は gateway からのみ、egress は DNS + HTTPS(443) + + SSH(22)、`blockedEgressCIDRs`(既定でクラウドメタデータ IP)を except。 + - Git ホスト allow-list(`git.allowedHosts`)。既定は allow-list 外を拒否。 + - `ResourceQuota` / `LimitRange` でセッション NS のブラスト半径を制限。 +- **CI** + - `helm-release.yaml`(main push で Package Registry へ publish、全リポジトリ共通)。 + - `image-update-and-release.yaml`(毎週月曜 3:00 JST: npm の Claude Code 最新版に追従、 + 主アプリ更新時のみ セッションイメージ build/push + タグ + リリース + publish)。 + +## バージョン + +`version` / `appVersion` = `2.1.259`(`@anthropic-ai/claude-code` の npm 最新版に一致)。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8330e43 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md — claude-code Helm Chart + +## リポジトリ概要 + +Alpine Linux ベースの軽量コンテナで **Claude Code をリモート実行** するための Helm チャート。 +ブラウザ(PC / スマートフォン)からアクセスし、**セッションごとに独立した使い捨て Pod** で +Git リポジトリ(GitHub / Gitea など互換 Git 全般)をチェックアウト・編集・検証・プッシュする。 +一定時間アイドルなセッション Pod は自動破棄される。Raspberry Pi などの ARM ベアメタル運用を想定。 + +- **Gitea リポジトリ**: `ssh://git@192.168.9.65/helmchart/claude-code` +- **Helm リポジトリ**: `https://git.cafepieters.com/api/packages/helmchart/helm` +- **セッションイメージ**: `git.cafepieters.com/helmchart/claude-code/session:`(CI で publish) + +## アーキテクチャ(要点) + +| コンポーネント | 実体 | 役割 | +|---|---|---| +| `gateway` | `nginxinc/nginx-unprivileged:alpine` | 単一入口。認証(token/basic/forward)、WebSocket リバースプロキシ、アクティビティ計測 | +| `manager` | `python:3.x-alpine`(標準ライブラリのみ) | セッション CRUD API、Web UI、アイドル/寿命回収ループ。in-cluster SA で K8s API を直接操作 | +| `session` | Alpine + node + git + ttyd + claude-code | セッションごとに作られる使い捨て Pod。`git clone` → `claude`(ttyd) → `claude-verify`/`claude-push` | +| `reaper` CronJob | `manager` イメージ `--reap-once` | manager 障害時の保険となる孤児掃除 | + +- 制御プレーンはリリース NS、セッション Pod は専用 NS(`sessionsNamespace.name`)に分離。 +- manager の RBAC はセッション NS の `pods` / `services` に限定した Role のみ(ClusterRole 不使用)。 +- セッション Pod は **API サーバー到達不可**(`automountServiceAccountToken: false`)、 + 非 root / `readOnlyRootFilesystem` / drop ALL caps / seccomp RuntimeDefault、 + egress は DNS + git ホスト + `api.anthropic.com` のみ(NetworkPolicy)。 +- ファイル実体は `files/`(`.Files.Get` で ConfigMap 化): `manager.py` / `session-entrypoint.sh` + / `gateway.conf.template` / `gateway-entrypoint.sh` / `Dockerfile.session`。 + +## 実行環境について + +**Helm はこのマシンにインストールされていない。** ローカルで `helm lint` / `template` は実行できない。 +テンプレートの検証はステージングのクラスタか CI(`helm-release.yaml` の lint ステップ)で行う。 + +## Git 運用ルール(このリポジトリで厳守) + +1. **機能を追加・変更したら、必ず `README.md` を更新してから作業完了とする。** + - values の新設キー、挙動変更、セキュリティ影響は README の該当セクションと + values リファレンス表へ反映する。 +2. **変更は必ず Git Commit / Push する。** + - 作業単位ごとにコミットし、`origin/main` へ push するまでが作業完了。 +3. **コミットメッセージに Claude クレジット(`Co-Authored-By` 等)を付けない。** + - `user.name = Claude` でコミットされるため、ユーザー名で判別可能。 +4. `git pull` で取り込んだ変更は常に尊重する。revert して push することは禁止。 + 不適切と思われる場合はユーザーに改善提案を提示し、承認を得てから対応する。 + +### Git Commit 情報 + +| 項目 | 値 | +|------|-----| +| user.name | `Claude` | +| user.email | `claude@cafepieters.com` | +| SSH キー | `P:/Claude/.ssh/id_claude` | +| core.sshCommand | `ssh -i P:/Claude/.ssh/id_claude -F P:/Claude/.ssh/config -o StrictHostKeyChecking=no` | + +## リリースフロー + +- **バージョン番号**: `Chart.yaml` の `version` / `appVersion` は + **主アプリ = Claude Code CLI(`@anthropic-ai/claude-code` の npm 最新版)と同一**にする。 +- **ワークフロー(3 本、他 Helm チャートプロジェクトと同じ思想)** + - `helm-release.yaml` — `main` への push で発火。`helm lint` の後、`Chart.yaml` の version が + 未リリース(Gitea リリース・Package Registry 双方に存在しない)なら タグ + Gitea リリース + + チャート publish。リリース済みなら lint のみで終了(再 publish しない)。 + - `image-update-and-release.yaml` — 毎週月曜 3:00 JST。npm の Claude Code 最新版と + base image(nginx / python)の最新 Alpine タグを確認。Claude Code 更新時は + `values.yaml` / `Chart.yaml` / README 履歴を更新しコミット → タグ + リリース + publish。 + base image のみ更新時は `values.yaml` を更新してコミットのみ(**リリースなし**)。 + - `session-image.yaml` — `files/Dockerfile.session` 等の変更、または上記による `Chart.yaml` + 更新で発火。セッションイメージを `linux/amd64,linux/arm64` でビルドして + Gitea Container Registry(`helmchart/claude-code/session:` + `latest`)へ push。 +- **手動リリース(臨時)**: 改修作業などで必要な場合はバージョン末尾にアルファベットを付与する + (例 `2.1.259` → `2.1.259-a` → `2.1.259-b`)。対応する `CHANGELOG-.md` を追加し、 + README の「バージョン履歴」にも 1 行追加する。 + +## 必要な Gitea Secrets + +| シークレット名 | 用途 | +|---|---| +| `GITEA_TOKEN` | Gitea API(リリース作成・タグ操作) | +| `REGISTRY_USER` | Gitea Package / Container Registry ユーザー名 | +| `REGISTRY_TOKEN` | Gitea Package / Container Registry トークン(`docker login` にも使用) | + +## 改修履歴 + +### 初版(2026-09-03, v2.1.259) + +チャート新規作成。gateway + manager + 使い捨て session Pod + reaper CronJob の構成で、 +ブラウザからの Claude Code リモート実行、GitHub/Gitea 両対応、アイドル自動破棄、 +NetworkPolicy / RBAC 最小化 / restricted PodSecurity を実装。 + +**対象ファイル**: 全ファイル(初期コミット) diff --git a/Chart.yaml b/Chart.yaml new file mode 100644 index 0000000..2338ab2 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1,32 @@ +apiVersion: v2 +name: claude-code +description: >- + Alpine ベースの軽量コンテナで Claude Code をリモート実行する Helm チャート。 + ブラウザ(PC / スマートフォン)からアクセスし、セッションごとに独立した + 使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・ + 検証・プッシュする。一定時間アイドルなセッションは自動破棄される。 +type: application +version: 2.1.259 +appVersion: "2.1.259" +keywords: + - claude-code + - claude + - anthropic + - remote-development + - ephemeral-environments + - git + - gitea + - github + - alpine + - arm +home: https://git.cafepieters.com/helmchart/claude-code +sources: + - https://git.cafepieters.com/helmchart/claude-code +maintainers: + - name: Claude + email: claude@cafepieters.com +icon: https://www.anthropic.com/images/icons/apple-touch-icon.png +annotations: + # 主アプリ = Claude Code CLI (@anthropic-ai/claude-code) の npm 最新版に追従する。 + # version / appVersion はこの CLI バージョンと同一に保つ(CLAUDE.md のリリースルール)。 + cafepieters.com/upstream-npm: "@anthropic-ai/claude-code" diff --git a/README.md b/README.md index a1e29db..86d1d6b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,371 @@ # claude-code -Alpine based container designed for running Claude Code remotely. \ No newline at end of file +**Alpine ベースの軽量コンテナで Claude Code をリモート実行する Helm チャート。** + +ブラウザ(PC / スマートフォン)からアクセスし、**セッションごとに独立した使い捨て Pod** の中で +Git リポジトリ(GitHub / Gitea など互換 Git 全般)をチェックアウト → 編集 → 検証 → プッシュします。 +一定時間アイドルなセッションは Pod ごと自動破棄され、リソースを占有し続けません。 +Raspberry Pi などの ARM ベアメタル Kubernetes を主な対象にしています。 + +> **設計目標** — Claude Code は本来ローカルで動かすものだが、これをクラスタにデプロイすることで、 +> Git 管理された複数プロジェクトを PC からもスマホからも横断的に、安全に触れるようにする。 + +- Gitea リポジトリ: `ssh://git@192.168.9.65/helmchart/claude-code` +- Helm リポジトリ: `https://git.cafepieters.com/api/packages/helmchart/helm` +- セッションイメージ: `git.cafepieters.com/helmchart/claude-code/session:` + +--- + +## 目次 + +- [アーキテクチャ](#アーキテクチャ) +- [セッションのライフサイクル](#セッションのライフサイクル) +- [セキュリティモデル](#セキュリティモデル) +- [インストール](#インストール) +- [使い方](#使い方) +- [values リファレンス](#values-リファレンス) + - [image](#image) + - [gateway / manager](#gateway--manager) + - [sessionsNamespace](#sessionsnamespace) + - [auth — アクセス認証](#auth--アクセス認証) + - [anthropic — Claude Code 認証情報](#anthropic--claude-code-認証情報) + - [git — 接続先と認証アイデンティティ](#git--接続先と認証アイデンティティ) + - [session — 使い捨てセッション](#session--使い捨てセッション) + - [networkPolicy](#networkpolicy) + - [ingress / service](#ingress--service) +- [セッションイメージ](#セッションイメージ) +- [運用](#運用) +- [トラブルシューティング](#トラブルシューティング) +- [リリースフロー](#リリースフロー) +- [バージョン履歴](#バージョン履歴) + +--- + +## アーキテクチャ + +``` + ┌──────────── リリース NS ────────────┐ + ブラウザ (PC/スマホ) │ Deployment: claude-code │ + │ HTTPS/WSS │ ┌──────────┐ ┌───────────────┐ │ + ▼ │ │ gateway │──▶│ manager │ │ + Ingress / LB ──────────▶│ │ (nginx) │ │ (python stdlib)│ │ + │ │ 認証 │ │ - Web UI │ │ + │ │ WS proxy │ │ - Session API │ │ + │ └────┬─────┘ │ - 回収ループ │ │ + │ │ └───────┬────────┘ │ + │ │ ttyd(WS) │ K8s API │ + └───────┼─────────────────┼───────────┘ + ▼ ▼ (Role: pods/services) + ┌───────── セッション NS (restricted PSA) ──────────┐ + │ Pod claude-sess-ab12cd34ef Pod claude-sess-… │ + │ ┌───────────────────────┐ │ + │ │ alpine + node + git │ git clone │ + │ │ + ttyd + claude-code │ → claude (ttyd) │ + │ │ 非root / roFS / netpol│ → claude-verify/push │ + │ └───────────────────────┘ アイドルで自壊 │ + └────────────────────────────────────────────────┘ +``` + +| コンポーネント | イメージ | 役割 | +|---|---|---| +| **gateway** | `nginxinc/nginx-unprivileged:*-alpine` | 単一入口。`auth.mode` に応じた認証、`/s//` をセッション Pod の `ttyd` へ WebSocket リバースプロキシ、リクエスト毎にアクティビティ時刻を更新 | +| **manager** | `python:3.x-alpine`(標準ライブラリのみ・追加 pip 不要) | モバイル対応 Web UI、セッション作成/一覧/破棄 API、アイドル/寿命の回収ループ。in-cluster ServiceAccount で Kubernetes API を直接操作 | +| **session** | `git.cafepieters.com/helmchart/claude-code/session`(既定)または任意の node:alpine 系 | セッションごとに作られる **使い捨て Pod**。`git clone` → `claude` を `ttyd` でブラウザに提供 | +| **reaper** | manager と同じイメージ(`--reap-once`) | manager 障害時の保険となる孤児掃除 CronJob | + +manager / gateway / session-entrypoint の実体は `files/` にあり、`.Files.Get` で ConfigMap 化されます。 + +## セッションのライフサイクル + +1. **作成** — UI か `POST /api/sessions` に `repo` / `ref` / `identity` を渡す。manager は + - リポジトリ URL の形式と **Git ホスト allow-list**(`git.allowedHosts`)を検証 + - 同時セッション数(`session.maxSessions`)を確認 + - セッション NS に Pod(`claude-sess-<10hex>`)と Service を作成 +2. **ブート**(`files/session-entrypoint.sh`, 非 root) + - マウントされた SSH 鍵 / HTTPS トークンで git 認証を構成(`known_hosts` 必須、TOFU 無効) + - リポジトリを `session.workspaceDir` に `clone`(`--filter=blob:none`) + - `claude` が無ければ `$HOME` に非 root で npm インストール + - `ttyd` を起動(`session.autoStartClaude: true` なら `claude` を自動起動) +3. **利用** — `/s//` 経由でブラウザ内ターミナルに接続。`claude-verify` / `claude-push` が使える +4. **破棄** — 次のいずれかで Pod ごと消滅(Service も回収) + - **アイドル**: `ttyd` への接続が `session.idleTimeoutSeconds` 秒間ゼロ → Pod 内ウォッチドッグが自壊 + - **アイドル(保険)**: manager の回収ループが `last-activity` アノテーションを見て削除 + - **最大寿命**: `session.maxLifetimeSeconds`(Pod の `activeDeadlineSeconds`)到達で強制終了 + - **手動**: UI の「破棄」/ `DELETE /api/sessions/` + - **孤児掃除**: 予備 CronJob(`session.reaper.cronjob`) + +## セキュリティモデル + +| 層 | 対策 | +|---|---| +| **入口** | すべて gateway 経由。`auth.mode`= `token`(既定)/ `basic` / `forward`(外部 SSO へ委譲)/ `none`。WebSocket は Cookie トークンで認証(ブラウザは WS に Authorization ヘッダを付けられないため) | +| **制御プレーン RBAC** | manager はセッション NS の `pods` / `services` のみ操作する **Role**(ClusterRole 不使用)。他 NS・他リソースには触れない | +| **セッション Pod** | `automountServiceAccountToken: false`(**API サーバー到達不可**)、`runAsNonRoot` / `readOnlyRootFilesystem` / `capabilities.drop: [ALL]` / `seccompProfile: RuntimeDefault`。書き込み可能なのは emptyDir(`/workspace`, `$HOME`, `/tmp`)のみ | +| **ネットワーク** | NetworkPolicy でセッションの ingress は gateway からのみ。egress は DNS + HTTPS(443, git/Anthropic) + SSH(22)。`networkPolicy.blockedEgressCIDRs`(既定でクラウドメタデータ IP `169.254.169.254/32`)を egress の `except` に指定。自クラスタの Pod/Service CIDR を追加すると内部 SSRF も遮断できる | +| **Git 到達先** | `git.allowedHosts` の allow-list。既定は allow-list 外を拒否(`allowArbitraryRepos: true` で解除) | +| **認証情報** | `ANTHROPIC_API_KEY` / SSH 鍵 / トークンは Kubernetes Secret。`values.yaml` に平文で書かず `--set` / `--set-file` / `existingSecret` で注入。git HTTPS トークンはセッション内で `credential.helper store` の 0600 ファイルに限定し、プロセス環境に残さない | +| **PodSecurity** | セッション NS に `pod-security.kubernetes.io/enforce: restricted` を付与(`sessionsNamespace.create: true` 時) | +| **ブラスト半径** | `ResourceQuota`(Pod / Service 数)+ `LimitRange`(コンテナ CPU/メモリ上限)+ `session.maxSessions` | +| **自動 push** | 既定 **無効**。有効時もデフォルト/保護ブランチ(`autoPush.protectedBranches`)への push と `--force` は不可。`branchPrefix` 配下の作業ブランチにのみ push | + +> **注意** — `values.yaml` に API キーや秘密鍵を書いてコミットしないでください。`helm get values` / +> CI ログ / Git 履歴に残ります。`existingSecret` か CI からの `--set` を使ってください。 + +## インストール + +### 前提 + +- Kubernetes 1.25+(PodSecurity admission)、NetworkPolicy 対応 CNI(Calico / Cilium など) +- Ingress コントローラー(外部公開する場合。WebSocket を通すため read/send timeout を延長) +- `ANTHROPIC_API_KEY`(または `claude setup-token` で発行した OAuth トークン) + +### Helm リポジトリから + +```bash +helm repo add cafepieters https://git.cafepieters.com/api/packages/helmchart/helm +helm repo update +helm upgrade --install claude-code cafepieters/claude-code \ + -n claude-code --create-namespace \ + -f my.values.yaml \ + --set anthropic.apiKey="$ANTHROPIC_API_KEY" +``` + +### ローカルチャートから(GitHub 公開リポジトリだけ触る最小構成) + +```bash +helm upgrade --install claude-code ./claude-code \ + -n claude-code --create-namespace \ + -f examples/minimal.values.yaml \ + --set anthropic.apiKey="$ANTHROPIC_API_KEY" +``` + +### Gitea(SSH) + GitHub(HTTPS) 両対応 + +```bash +helm upgrade --install claude-code ./claude-code \ + -n claude-code --create-namespace \ + -f examples/gitea-and-github.values.yaml \ + --set anthropic.apiKey="$ANTHROPIC_API_KEY" \ + --set-file git.identities.gitea-ssh.sshPrivateKey=P:/Claude/.ssh/id_claude \ + --set-file git.identities.gitea-ssh.knownHosts=./known_hosts.gitea \ + --set git.identities.github-https.token="$GITHUB_PAT" +``` + +`known_hosts` は事前に取得します: + +```bash +ssh-keyscan 192.168.9.65 > known_hosts.gitea +``` + +## 使い方 + +1. アクセス(Ingress 無効ならポートフォワード): + + ```bash + kubectl -n claude-code port-forward svc/claude-code 8080:80 + ``` + +2. `auth.mode: token` の場合、トークンを取得して UI に貼り付け(または `?token=` で開く): + + ```bash + kubectl -n claude-code get secret claude-code-auth \ + -o jsonpath='{.data.token}' | base64 -d ; echo + ``` + +3. UI で **リポジトリ URL** / **ブランチ** / **認証 identity** を指定して「セッション作成」。 +4. 一覧の「開く」でブラウザ内ターミナル(Claude Code)に接続。 +5. セッション内の補助コマンド: + - `claude-verify` … `session.verifyCommand`(例 `npm test`)を実行 + - `claude-push` … 検証 → `claude/` へ push(保護ブランチ / `--force` 不可) +6. 無操作 `session.idleTimeoutSeconds` 秒でセッション(Pod)は自動的に消えます。 + +## values リファレンス + +### image + +| キー | 既定 | 説明 | +|---|---|---| +| `image.gateway.*` | `nginxinc/nginx-unprivileged:1.29.1-alpine` | 受付 nginx(非 root 版) | +| `image.manager.*` | `library/python:3.13-alpine` | 制御プレーン(標準ライブラリのみ) | +| `image.session.*` | `library/node:22-alpine` | セッション。既定はブート時に `apk`/`npm` を実行するため初回起動が遅い。[セッションイメージ](#セッションイメージ)参照 | +| `imagePullSecrets` | `[]` | プライベートレジストリ用 | + +### gateway / manager + +| キー | 既定 | 説明 | +|---|---|---| +| `gateway.replicaCount` | `1` | 制御プレーン Deployment のレプリカ数 | +| `gateway.port` | `8080` | gateway コンテナ待受ポート(Service / Ingress バックエンド) | +| `manager.logLevel` | `info` | `debug` / `info` / `warning` / `error` | +| `gateway.resources` / `manager.resources` | 控えめ | それぞれの resources | + +### sessionsNamespace + +| キー | 既定 | 説明 | +|---|---|---| +| `sessionsNamespace.name` | `claude-code-sessions` | セッション Pod を隔離する NS(空 = リリース NS) | +| `sessionsNamespace.create` | `true` | この NS を作成し PodSecurity ラベルを付与するか。既存 NS を使うなら `false` にして手動でラベル付与 | +| `sessionsNamespace.podSecurityLevel` | `restricted` | 付与する PodSecurity `enforce` レベル | +| `sessionsNamespace.resourceQuota.enabled` | `true` | セッション NS の `ResourceQuota`(既定は Pod/Service 数の抑制のみ。compute 集計上限は `hard` で明示) | +| `sessionsNamespace.resourceQuota.hard` | `{}` | 指定するとそのまま `spec.hard` | +| `sessionsNamespace.limitRange.*` | max 4CPU/4Gi | セッション NS のコンテナ既定値・上限(`LimitRange`) | + +### auth — アクセス認証 + +| キー | 既定 | 説明 | +|---|---|---| +| `auth.mode` | `token` | `token` / `basic` / `forward` / `none` | +| `auth.token.value` | `""` | 空なら install 時に自動生成(`lookup` で再 install 間も安定)。運用では `existingSecret` 推奨 | +| `auth.token.existingSecret` / `existingSecretKey` | `""` / `token` | 既存 Secret からトークンを読む | +| `auth.basic.users` | `[]` | `htpasswd -nbB user pass` の出力を 1 行以上 | +| `auth.basic.existingSecret` / `existingSecretKey` | `""` / `htpasswd` | 既存 htpasswd Secret | +| `auth.forward.url` | `""` | `auth_request` の転送先(例: oauth2-proxy の `/oauth2/auth`) | +| `auth.forward.signInUrl` | `""` | 未認証時のサインイン URL(任意) | + +`token` モードでは UI のログインフォームで一度トークンを入力すると、`HttpOnly` Cookie に保存され +以降の `/s//`(WebSocket 含む)に自動的に付与されます。 + +### anthropic — Claude Code 認証情報 + +| キー | 既定 | 説明 | +|---|---|---| +| `anthropic.authMode` | `apiKey` | `apiKey`(`ANTHROPIC_API_KEY`)/ `oauth`(`CLAUDE_CODE_OAUTH_TOKEN`) | +| `anthropic.apiKey` / `anthropic.oauthToken` | `""` | `--set` / `--set-file` で注入 | +| `anthropic.existingSecret` | `""` | 既存 Secret を使う(キー名は `existingSecretKeyApiKey` / `existingSecretKeyOauthToken`) | +| `anthropic.extraEnv` | `{}` | 全セッションへ渡す追加 env(例 `ANTHROPIC_BASE_URL`、Bedrock/Vertex 切替など) | + +Secret は **セッション NS** に作成され、セッション Pod へ `envFrom` で注入されます。 + +### git — 接続先と認証アイデンティティ + +| キー | 既定 | 説明 | +|---|---|---| +| `git.allowedHosts` | GitHub/GitLab/Gitea | clone を許可するホスト allow-list | +| `git.allowArbitraryRepos` | `false` | allow-list を無効化し任意 URL を許可(非推奨) | +| `git.defaultAuthorName` / `Email` | `Claude` / `claude@cafepieters.com` | コミット author 既定 | +| `git.identities..type` | — | `ssh` / `https-token` | +| `…ssh.sshPrivateKey` / `knownHosts` | — | PEM 秘密鍵 / `ssh-keyscan` 出力(**`knownHosts` 必須**) | +| `…https-token.username` / `token` | `x-access-token` / — | HTTPS ベーシック(GitHub PAT は username 任意) | +| `…existingSecret` ほか | — | 既存 Secret 参照(キー名は `existingSecretKey*` で調整) | + +GitHub でも Gitea でも GitLab でも、SSH か HTTPS トークンで認証できる Git ホストなら同じ仕組みで扱えます。 +セッション作成時に identity 名を選ぶと、その Secret だけがそのセッション Pod にマウントされます。 + +### session — 使い捨てセッション + +| キー | 既定 | 説明 | +|---|---|---| +| `session.autoStartClaude` | `true` | ブート後に `claude` を自動起動(`false` はログインシェル) | +| `session.workspaceDir` | `/workspace` | clone 先 | +| `session.ttydPort` | `7681` | `ttyd` 待受(Pod 内のみ) | +| `session.extraApkPackages` | `[]` | 追加 apk(事前ビルドイメージなど root 権限がある場合のみ有効) | +| `session.claudeCodeVersion` | `""` | インストールする `@anthropic-ai/claude-code` バージョン(空 = チャート `appVersion`) | +| `session.resources` | 100m/256Mi 〜 2CPU/2Gi/4Gi(eph) | セッション Pod の resources | +| `session.idleTimeoutSeconds` | `1800` | 無通信での破棄しきい値 | +| `session.maxLifetimeSeconds` | `43200` | Pod の `activeDeadlineSeconds` | +| `session.reaper.intervalSeconds` | `60` | manager 回収ループ間隔 | +| `session.reaper.cronjob.enabled` / `schedule` | `true` / `*/15 * * * *` | 予備の孤児掃除 CronJob | +| `session.maxSessions` | `10` | 同時セッション数上限(manager が enforce) | +| `session.verifyCommand` | `""` | `claude-verify` / `claude-push` が実行するコマンド | +| `session.autoPush.enabled` | `false` | `claude-push` を許可するか | +| `session.autoPush.branchPrefix` | `claude/` | push 先ブランチのプレフィックス | +| `session.autoPush.protectedBranches` | `[main, master, develop, release]` | push 禁止ブランチ | +| `session.podLabels` / `podAnnotations` / `nodeSelector` / `tolerations` / `priorityClassName` | — | セッション Pod へのメタデータ / スケジューリング | + +### networkPolicy + +| キー | 既定 | 説明 | +|---|---|---| +| `networkPolicy.enabled` | `true` | NetworkPolicy を作成するか(CNI が対応必須) | +| `networkPolicy.dnsNamespaceSelector` | `kube-system` | DNS を許可する NS セレクタ | +| `networkPolicy.allowInternetHttps` | `true` | セッションの 443/tcp 外向きを許可(git HTTPS + Anthropic API) | +| `networkPolicy.allowSshGit` | `true` | セッションの 22/tcp 外向きを許可(SSH git) | +| `networkPolicy.blockedEgressCIDRs` | `[169.254.169.254/32]` | egress `except`。自クラスタの Pod/Service CIDR を足すと内部 SSRF を遮断 | +| `networkPolicy.sessionEgressExtra` | `[]` | 追加許可(社内ミラー / プロキシ)。`{cidr, ports[]}` | + +完全にインターネットを遮断したい場合は `allowInternetHttps: false` にして egress プロキシ +(例: 許可リスト付き Squid)を `sessionEgressExtra` で通してください。 + +### ingress / service + +`ingress.enabled: true` で Ingress を作成。WebSocket 用に `proxy-read-timeout` / +`proxy-send-timeout` を既定 `3600` に設定します(`ingress.annotations` で上書き可)。 +`service.type` は `ClusterIP` 既定。 + +## セッションイメージ + +既定の `image.session` は CI(`session-image.yaml`)がビルドする **事前ビルドイメージ** +`git.cafepieters.com/helmchart/claude-code/session:` です。 +`git` / `openssh-client` / `ttyd` / `bash` / `node` / `@anthropic-ai/claude-code` を同梱し、 +非 root(uid 10001)で動作するため、`restricted` PodSecurity / `readOnlyRootFilesystem` 下でも +確実に起動し、コールドスタートも数秒です。 + +`session-image.yaml` は `files/Dockerfile.session` 等の変更 push、または +`image-update-and-release.yaml` による `Chart.yaml` 更新で発火し、`linux/amd64,linux/arm64` の +2 アーキテクチャでビルドして Gitea Container Registry へ push します。手動ビルドは: + +```bash +docker buildx build --platform linux/amd64,linux/arm64 \ + --build-arg CLAUDE_CODE_VERSION=2.1.259 \ + -f files/Dockerfile.session \ + -t git.cafepieters.com/helmchart/claude-code/session:2.1.259 --push . +``` + +### 事前ビルドを使わない場合 + +`image.session` に素の `node:22-alpine` などを指定することもできます。この場合ブート +スクリプトが `$HOME` に非 root で `@anthropic-ai/claude-code` を npm インストールします +(初回数十秒)。ただし **`ttyd` は非 root では `apk add` できない** ため、`ttyd` を含まない +イメージでは起動に失敗します。`ttyd` 入りの独自イメージを用意するか、事前ビルドイメージを +使ってください。 + +## 運用 + +- **セッション一覧**: `kubectl -n get pods -l app.kubernetes.io/component=session` +- **セッションのログ**: `kubectl -n logs claude-sess-` +- **強制破棄**: UI から、または `kubectl -n delete pod,svc claude-sess-` +- **回収の確認**: manager のログに `reaping session (idle|max-lifetime|phase=...)` +- **トークンのローテーション**: `auth.token.existingSecret` の値を更新し、Deployment を再起動 +- **アップグレード**: `helm upgrade`。`files/*` の変更は checksum アノテーションで自動ローリング + +## トラブルシューティング + +| 症状 | 原因と対処 | +|---|---| +| UI が 502 / セッションを開くと 502 | セッション Pod がまだ Ready でない(初回は `npm i` に時間)。数十秒待つ。恒久対策は事前ビルドイメージ | +| セッション作成が `許可されていない Git ホスト` | `git.allowedHosts` に対象ホストを追加(IP と FQDN は別物) | +| clone が `Host key verification failed` | identity の `knownHosts` が対象ホストの鍵と不一致。`ssh-keyscan ` で取り直す | +| clone が `terminated ... could not read Username` | HTTPS の場合 `git.identities.` 未指定 or token 無効。SSH の場合は identity の type/秘密鍵を確認 | +| WebSocket がすぐ切れる | Ingress の `proxy-read-timeout` を延長。`auth.mode: token` で Cookie 未設定なら一度 UI からログイン | +| セッションが破棄されない | manager の RBAC / ログを確認。予備 CronJob(`session.reaper.cronjob`)が有効か | +| `apk` エラーがログに出る | 既定イメージ + `readOnlyRootFilesystem` の制約。事前ビルドイメージを使う | +| manager が全リクエストを 401 | `auth.mode: token` でトークンが空。`auth.token.value` か `existingSecret` を設定 | + +## リリースフロー + +`Chart.yaml` の `version` / `appVersion` は **Claude Code CLI(`@anthropic-ai/claude-code` npm 最新版)と同一**。 + +- **`helm-release.yaml`** — `main` push 時に Gitea Package Registry へチャートを publish(全リポジトリ共通)。 +- **`image-update-and-release.yaml`** — 毎週月曜 3:00 JST。 + - npm の Claude Code 最新版に追従。**主アプリ更新時のみ** `Chart.yaml` 更新 → セッションイメージ + multi-arch ビルド/push → Git タグ `v` → Gitea リリース → チャート publish。 + - `nginx` / `python` の Alpine タグのみ更新の場合は `values.yaml` を更新してコミットするだけ + (**リリースなし**)。 +- **手動リリース(臨時)** — バージョン末尾にアルファベットを付与(`2.1.259` → `2.1.259-a`)。 + 対応する `CHANGELOG-.md` を追加する。 + +必要な Gitea Secrets: `GITEA_TOKEN` / `REGISTRY_USER` / `REGISTRY_TOKEN`。 + +### ワークフロー一覧 + +| ファイル | トリガー | 動作 | +|---|---|---| +| `helm-release.yaml` | `main` への push / 手動 | `helm lint`。`Chart.yaml` の version が未リリース(Gitea リリース・Package Registry 双方に無い)なら タグ + Gitea リリース + チャート publish。リリース済みなら lint のみ | +| `image-update-and-release.yaml` | 毎週月曜 3:00 JST / 手動 | npm の `@anthropic-ai/claude-code` 最新版と base image(nginx / python)の最新 Alpine タグを確認。Claude Code の更新があれば `values.yaml` / `Chart.yaml` / README 履歴を更新してコミット → タグ + リリース + publish。base image のみの更新は `values.yaml` を更新してコミットのみ(リリースなし) | +| `session-image.yaml` | `files/Dockerfile.session` ほかの変更 / 手動 | セッションイメージ(`.../claude-code/session`)を `linux/amd64,linux/arm64` でビルドし、`Chart.yaml` の `appVersion` タグ + `latest` で Gitea Container Registry へ push | + +## バージョン履歴 + +| バージョン | Claude Code | 変更内容 | +|---|---|---| +| 2.1.259 | 2.1.259 | 初版。gateway + manager + 使い捨て session Pod + reaper、GitHub/Gitea 対応、アイドル自動破棄、NetworkPolicy / RBAC 最小化 / restricted PodSecurity | diff --git a/examples/gitea-and-github.values.yaml b/examples/gitea-and-github.values.yaml new file mode 100644 index 0000000..367ff01 --- /dev/null +++ b/examples/gitea-and-github.values.yaml @@ -0,0 +1,69 @@ +# Gitea(SSH) + GitHub(HTTPS トークン) の両対応構成 + Ingress + 事前ビルド image +# +# 実行例: +# helm upgrade --install claude-code ./claude-code \ +# -n claude-code --create-namespace \ +# -f examples/gitea-and-github.values.yaml \ +# --set anthropic.apiKey="$ANTHROPIC_API_KEY" \ +# --set-file git.identities.gitea-ssh.sshPrivateKey=/path/to/id_claude \ +# --set-file git.identities.gitea-ssh.knownHosts=/path/to/known_hosts \ +# --set git.identities.github-https.token="$GITHUB_PAT" + +image: + session: + # CI で publish した事前ビルドイメージ(git + ttyd + claude-code 同梱) + registry: git.cafepieters.com + repository: helmchart/claude-code/session + tag: "2.1.259" + +auth: + mode: token + token: + existingSecret: claude-code-access # 事前に kubectl create secret generic で作成 + +anthropic: + authMode: apiKey + +git: + allowedHosts: + - github.com + - 192.168.9.65 + - git.cafepieters.com + defaultAuthorName: "Claude" + defaultAuthorEmail: "claude@cafepieters.com" + identities: + gitea-ssh: + type: ssh + # sshPrivateKey / knownHosts は --set-file で注入 + # knownHosts は `ssh-keyscan 192.168.9.65` の出力 + github-https: + type: https-token + username: "x-access-token" + # token は --set で注入 + +session: + autoStartClaude: true + idleTimeoutSeconds: 1800 + maxLifetimeSeconds: 28800 + maxSessions: 8 + verifyCommand: "npm test --silent" + autoPush: + enabled: true + branchPrefix: "claude/" + +ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + hosts: + - host: claude-code.cafepieters.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: claude-code-tls + hosts: + - claude-code.cafepieters.com diff --git a/examples/minimal.values.yaml b/examples/minimal.values.yaml new file mode 100644 index 0000000..68cbe11 --- /dev/null +++ b/examples/minimal.values.yaml @@ -0,0 +1,26 @@ +# 最小構成: 公開リポジトリのみ・token 認証・Ingress なし +# 実行例: +# helm upgrade --install claude-code ./claude-code \ +# -n claude-code --create-namespace \ +# -f examples/minimal.values.yaml \ +# --set anthropic.apiKey="$ANTHROPIC_API_KEY" +# +# アクセス: +# kubectl -n claude-code port-forward svc/claude-code-claude-code 8080:80 +# トークン: kubectl -n claude-code get secret claude-code-claude-code-auth -o jsonpath='{.data.token}' | base64 -d + +auth: + mode: token # 空 value → install 時に自動生成 + +anthropic: + authMode: apiKey + # apiKey は --set か existingSecret で注入する + +git: + allowedHosts: + - github.com + - gitlab.com + +session: + idleTimeoutSeconds: 900 + maxSessions: 3 diff --git a/examples/oidc-forward-auth.values.yaml b/examples/oidc-forward-auth.values.yaml new file mode 100644 index 0000000..0cded61 --- /dev/null +++ b/examples/oidc-forward-auth.values.yaml @@ -0,0 +1,41 @@ +# 外部 SSO(oauth2-proxy)へ認証を委譲する構成。 +# ブラウザ → Ingress → (oauth2-proxy) → claude-code gateway。 +# gateway 自身も auth_request で二重チェックする。 +# +# 前提: auth ネームスペースに oauth2-proxy がデプロイ済みで、 +# http://oauth2-proxy.auth.svc.cluster.local:4180/oauth2/auth が使えること。 + +auth: + mode: forward + forward: + url: "http://oauth2-proxy.auth.svc.cluster.local:4180/oauth2/auth" + signInUrl: "https://auth.example.com/oauth2/start?rd=$escaped_request_uri" + +anthropic: + authMode: oauth # `claude setup-token` で発行したトークンを使う + # oauthToken は --set / existingSecret で注入 + +git: + allowedHosts: + - github.com + - git.cafepieters.com + +session: + maxSessions: 15 + idleTimeoutSeconds: 3600 + +ingress: + enabled: true + className: nginx + annotations: + nginx.ingress.kubernetes.io/auth-url: "https://auth.example.com/oauth2/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://auth.example.com/oauth2/start?rd=$escaped_request_uri" + hosts: + - host: claude-code.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: claude-code-tls + hosts: + - claude-code.example.com diff --git a/files/Dockerfile.session b/files/Dockerfile.session new file mode 100644 index 0000000..773e2f8 --- /dev/null +++ b/files/Dockerfile.session @@ -0,0 +1,37 @@ +# ========================================================================== +# claude-code セッションイメージ(事前ビルド版・推奨) +# +# 既定の values.image.session(node:22-alpine + ブート時インストール)でも +# 動作するが、初回起動が遅い。このイメージを CI でビルド・publish し +# values.image.session に指定すると、セッションのコールドスタートが数秒に短縮される。 +# +# ビルド: +# docker buildx build --platform linux/amd64,linux/arm64 \ +# --build-arg CLAUDE_CODE_VERSION=2.1.259 \ +# -f files/Dockerfile.session \ +# -t git.cafepieters.com/helmchart/claude-code/session:2.1.259 --push . +# ========================================================================== +ARG NODE_VERSION=22-alpine +FROM node:${NODE_VERSION} + +ARG CLAUDE_CODE_VERSION="" + +# git(SSH 込み) / ttyd / bash / 基本ツール。community に ttyd がある。 +RUN apk add --no-cache \ + bash git openssh-client ca-certificates curl jq less tini \ + ttyd \ + && update-ca-certificates + +# Claude Code CLI をグローバル導入(ビルド時のみ root、実行時は read-only でOK) +RUN npm install -g --no-fund --no-audit \ + "@anthropic-ai/claude-code${CLAUDE_CODE_VERSION:+@${CLAUDE_CODE_VERSION}}" \ + && npm cache clean --force + +# 非 root 実行ユーザー(Helm 側の runAsUser と一致させる: 既定 10001) +ARG RUN_UID=10001 +RUN adduser -D -u ${RUN_UID} -h /home/claude claude +USER ${RUN_UID} +ENV HOME=/home/claude + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["/bin/sh", "/bootstrap/entrypoint.sh"] diff --git a/files/gateway-entrypoint.sh b/files/gateway-entrypoint.sh new file mode 100644 index 0000000..ece3d81 --- /dev/null +++ b/files/gateway-entrypoint.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# claude-code gateway 起動スクリプト +# - /etc/resolv.conf から nginx 用 resolver を導出 +# - auth.mode に応じて認証インクルードを生成 +# - envsubst で nginx.conf を展開して起動 +set -eu + +TPL="/etc/gateway/gateway.conf.template" +OUT_DIR="/tmp/nginx" +mkdir -p "$OUT_DIR/body" "$OUT_DIR/proxy" "$OUT_DIR/fastcgi" "$OUT_DIR/uwsgi" "$OUT_DIR/scgi" + +: "${GATEWAY_PORT:=8080}" +: "${MANAGER_PORT:=8081}" +: "${TTYD_PORT:=7681}" +: "${SESSIONS_NS:=default}" +: "${AUTH_MODE:=token}" +: "${NGINX_ERROR_LEVEL:=warn}" + +# ---- resolver(CoreDNS)を resolv.conf から取得 ---- +NGINX_RESOLVER="$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null || true)" +[ -n "$NGINX_RESOLVER" ] || NGINX_RESOLVER="kube-dns.kube-system.svc.cluster.local" +export NGINX_RESOLVER + +export GATEWAY_PORT MANAGER_PORT TTYD_PORT SESSIONS_NS NGINX_ERROR_LEVEL + +# ---- 認証インクルードの生成 ---- +: > "$OUT_DIR/auth_basic.conf" +: > "$OUT_DIR/auth_request.conf" +: > "$OUT_DIR/auth_backend.conf" + +case "$AUTH_MODE" in + token) + echo 'auth_request /_auth;' > "$OUT_DIR/auth_request.conf" + echo "proxy_pass http://127.0.0.1:${MANAGER_PORT}/auth;" > "$OUT_DIR/auth_backend.conf" + ;; + forward) + if [ -z "${AUTH_FORWARD_URL:-}" ]; then + echo "AUTH_MODE=forward だが AUTH_FORWARD_URL が空です" >&2; exit 1 + fi + echo 'auth_request /_auth;' > "$OUT_DIR/auth_request.conf" + { + echo "proxy_pass ${AUTH_FORWARD_URL};" + echo 'proxy_set_header X-Original-URL $scheme://$http_host$request_uri;' + } > "$OUT_DIR/auth_backend.conf" + ;; + basic) + if [ ! -r /etc/gateway-secret/htpasswd ]; then + echo "AUTH_MODE=basic だが htpasswd がマウントされていません" >&2; exit 1 + fi + { + echo 'auth_basic "claude-code";' + echo 'auth_basic_user_file /etc/gateway-secret/htpasswd;' + } > "$OUT_DIR/auth_basic.conf" + ;; + none) + echo "WARNING: AUTH_MODE=none — 認証なしで公開されます" >&2 + ;; + *) + echo "未知の AUTH_MODE: $AUTH_MODE" >&2; exit 1 + ;; +esac + +# ---- nginx.conf 展開 ---- +envsubst '${GATEWAY_PORT} ${MANAGER_PORT} ${TTYD_PORT} ${SESSIONS_NS} ${NGINX_RESOLVER} ${NGINX_ERROR_LEVEL}' \ + < "$TPL" > "$OUT_DIR/nginx.conf" + +echo "[gateway] auth_mode=$AUTH_MODE resolver=$NGINX_RESOLVER sessions_ns=$SESSIONS_NS" +exec nginx -c "$OUT_DIR/nginx.conf" -g 'daemon off;' diff --git a/files/gateway.conf.template b/files/gateway.conf.template new file mode 100644 index 0000000..12e5e37 --- /dev/null +++ b/files/gateway.conf.template @@ -0,0 +1,100 @@ +# nginx.conf (envsubst テンプレート) — claude-code gateway +# 受付・認証・WebSocket プロキシの単一入口。read-only rootfs / 非 root で動作する。 +worker_processes auto; +pid /tmp/nginx/nginx.pid; +error_log /dev/stderr ${NGINX_ERROR_LEVEL}; + +events { worker_connections 1024; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + + client_body_temp_path /tmp/nginx/body; + proxy_temp_path /tmp/nginx/proxy; + fastcgi_temp_path /tmp/nginx/fastcgi; + uwsgi_temp_path /tmp/nginx/uwsgi; + scgi_temp_path /tmp/nginx/scgi; + + sendfile on; + tcp_nopush on; + server_tokens off; + client_max_body_size 25m; + + map $http_upgrade $connection_upgrade { default upgrade; '' close; } + + resolver ${NGINX_RESOLVER} valid=10s ipv6=off; + + server { + listen ${GATEWAY_PORT}; + listen [::]:${GATEWAY_PORT}; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy no-referrer always; + + # ---- HTTP Basic(auth.mode=basic のときのみ内容を持つ)---- + include /tmp/nginx/auth_basic.conf; + + location = /healthz { + auth_basic off; + proxy_pass http://127.0.0.1:${MANAGER_PORT}/healthz; + } + + # ---- auth_request の実体(auth.mode=token|forward)---- + location = /_auth { + internal; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header X-Original-Method $request_method; + include /tmp/nginx/auth_backend.conf; + } + + # ---- 制御プレーン(UI / API)---- + location / { + include /tmp/nginx/auth_request.conf; + proxy_pass http://127.0.0.1:${MANAGER_PORT}; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # UI とログインは常に到達可能にする(token モードのログイン導線のため) + location = / { proxy_pass http://127.0.0.1:${MANAGER_PORT}; proxy_set_header Host $host; } + location = /index.html { proxy_pass http://127.0.0.1:${MANAGER_PORT}; proxy_set_header Host $host; } + location = /login { proxy_pass http://127.0.0.1:${MANAGER_PORT}; proxy_set_header Host $host; } + location = /api/config { + include /tmp/nginx/auth_request.conf; + proxy_pass http://127.0.0.1:${MANAGER_PORT}; + proxy_set_header Host $host; + } + + # ---- セッションへのリバースプロキシ(WebSocket 対応)---- + location ~ ^/s/(?[a-f0-9]{10})(?/.*)?$ { + include /tmp/nginx/auth_basic.conf; + include /tmp/nginx/auth_request.conf; + error_page 401 = @needlogin; + + set $rest_path $rest; + if ($rest_path = "") { set $rest_path /; } + set $sess_backend "http://claude-sess-$sid.${SESSIONS_NS}.svc.cluster.local:${TTYD_PORT}"; + + proxy_pass $sess_backend$rest_path$is_args$args; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } + + location @needlogin { + return 302 /?next=$request_uri; + } + } +} diff --git a/files/manager.py b/files/manager.py new file mode 100644 index 0000000..773b18b --- /dev/null +++ b/files/manager.py @@ -0,0 +1,685 @@ +#!/usr/bin/env python3 +""" +claude-code manager — 制御プレーン + +責務: + * Web UI(モバイル対応・ビルド不要の素の HTML/JS) + * セッションのライフサイクル API(作成 / 一覧 / 破棄 / touch) + * gateway(nginx) からの auth_request 検証(token モード) + * アイドル / 寿命超過セッションの回収ループ + +依存: Python 標準ライブラリのみ(追加 pip 不要)。 +Kubernetes API へは in-cluster ServiceAccount トークンで直接アクセスする。 +セッション NS 内の Pod / Service のみを操作する(RBAC で制限済み)。 +""" + +import base64 +import hmac +import html +import json +import logging +import os +import re +import secrets +import ssl +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# -------------------------------------------------------------------------- +# 設定(すべて環境変数から) +# -------------------------------------------------------------------------- +def env(key, default=""): + return os.environ.get(key, default) + +def env_json(key, default): + raw = os.environ.get(key, "").strip() + if not raw: + return default + try: + return json.loads(raw) + except ValueError: + logging.error("invalid JSON in env %s", key) + return default + +LISTEN_PORT = int(env("LISTEN_PORT", "8080")) +SESSIONS_NS = env("SESSIONS_NAMESPACE", "default") +FULLNAME = env("FULLNAME", "claude-code") +SESSION_SA = env("SESSION_SERVICE_ACCOUNT", "claude-code-session") +SESSION_IMAGE = env("SESSION_IMAGE", "docker.io/library/node:22-alpine") +SESSION_IMAGE_PULL_POLICY = env("SESSION_IMAGE_PULL_POLICY", "IfNotPresent") +SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", []) +BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session") +ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic") +GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {}) +GIT_ALLOWED_HOSTS = [h.strip().lower() for h in env("GIT_ALLOWED_HOSTS", "").split(",") if h.strip()] +GIT_ALLOW_ARBITRARY = env("GIT_ALLOW_ARBITRARY", "false").lower() == "true" +GIT_AUTHOR_NAME = env("GIT_DEFAULT_AUTHOR_NAME", "Claude") +GIT_AUTHOR_EMAIL = env("GIT_DEFAULT_AUTHOR_EMAIL", "claude@cafepieters.com") +IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800")) +MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200")) +REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60")) +MAX_SESSIONS = int(env("MAX_SESSIONS", "10")) +TTYD_PORT = int(env("TTYD_PORT", "7681")) +SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {}) +WORKSPACE_DIR = env("WORKSPACE_DIR", "/workspace") +AUTOSTART_CLAUDE = env("AUTOSTART_CLAUDE", "true") +VERIFY_COMMAND = env("VERIFY_COMMAND", "") +AUTOPUSH = env_json("AUTOPUSH_JSON", {"enabled": False}) +EXTRA_APK = env("EXTRA_APK_PACKAGES", "") +CLAUDE_CODE_VERSION = env("CLAUDE_CODE_VERSION", "") +ANTHROPIC_EXTRA_ENV = env_json("ANTHROPIC_EXTRA_ENV_JSON", {}) +POD_LABELS = env_json("SESSION_POD_LABELS_JSON", {}) +POD_ANNOTATIONS = env_json("SESSION_POD_ANNOTATIONS_JSON", {}) +NODE_SELECTOR = env_json("SESSION_NODE_SELECTOR_JSON", {}) +TOLERATIONS = env_json("SESSION_TOLERATIONS_JSON", []) +PRIORITY_CLASS = env("SESSION_PRIORITY_CLASS", "") +SESSION_RUN_UID = int(env("SESSION_RUN_UID", "10001")) + +AUTH_MODE = env("AUTH_MODE", "token") +_auth_token_file = env("AUTH_TOKEN_FILE", "") +if _auth_token_file and os.path.exists(_auth_token_file): + with open(_auth_token_file, "r", encoding="utf-8") as fh: + AUTH_TOKEN = fh.read().strip() +else: + AUTH_TOKEN = env("AUTH_TOKEN", "") + +logging.basicConfig( + level=getattr(logging, env("LOG_LEVEL", "info").upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(message)s", +) +log = logging.getLogger("manager") + +LABEL_APP = "app.kubernetes.io/name" +LABEL_COMPONENT = "app.kubernetes.io/component" +LABEL_SID = "claude-code.cafepieters.com/session-id" +ANN_PREFIX = "claude-code.cafepieters.com/" +SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code" + +SID_RE = re.compile(r"^[a-f0-9]{10}$") +# 受け入れる repo URL: https://host/... または ssh://git@host/... または git@host:path +REPO_RE = re.compile( + r"^(?:https://(?P

[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+" + r"|ssh://git@(?P

[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+" + r"|git@(?P

[A-Za-z0-9.\-]+):[\w./\-~%]+)$" +) +REF_RE = re.compile(r"^[\w./\-]{1,200}$") + +# -------------------------------------------------------------------------- +# Kubernetes API クライアント(標準ライブラリのみ) +# -------------------------------------------------------------------------- +K8S_HOST = "https://kubernetes.default.svc" +_sa_dir = "/var/run/secrets/kubernetes.io/serviceaccount" +with open(_sa_dir + "/token", "r", encoding="utf-8") as fh: + K8S_TOKEN = fh.read().strip() +_ssl_ctx = ssl.create_default_context(cafile=_sa_dir + "/ca.crt") + + +def k8s(method, path, body=None, ctype="application/json"): + url = K8S_HOST + path + data = None + if body is not None: + data = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8") + req = urllib.request.Request(url=url, method=method, data=data) + req.add_header("Authorization", "Bearer " + K8S_TOKEN) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", ctype) + try: + with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp: + raw = resp.read() + return resp.status, (json.loads(raw) if raw else {}) + except urllib.error.HTTPError as exc: + raw = exc.read() + try: + payload = json.loads(raw) + except ValueError: + payload = {"message": raw.decode("utf-8", "replace")} + return exc.code, payload + + +def core(path): + return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path + +# -------------------------------------------------------------------------- +# セッション操作 +# -------------------------------------------------------------------------- +def repo_host(url): + m = REPO_RE.match(url) + if not m: + return None + return (m.group("h1") or m.group("h2") or m.group("h3") or "").lower() + + +def list_sessions(): + status, payload = k8s("GET", core("/pods?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR))) + if status != 200: + log.error("list pods failed: %s %s", status, payload) + return [] + out = [] + for pod in payload.get("items", []): + meta = pod.get("metadata", {}) + ann = meta.get("annotations", {}) + st = pod.get("status", {}) + out.append({ + "id": meta.get("labels", {}).get(LABEL_SID, ""), + "phase": st.get("phase", "Unknown"), + "repo": ann.get(ANN_PREFIX + "repo", ""), + "ref": ann.get(ANN_PREFIX + "ref", ""), + "identity": ann.get(ANN_PREFIX + "git-identity", ""), + "owner": ann.get(ANN_PREFIX + "owner", ""), + "createdAt": ann.get(ANN_PREFIX + "created-at", ""), + "lastActivity": ann.get(ANN_PREFIX + "last-activity", ""), + }) + return out + + +def active_session_count(): + return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running")) + + +def build_pod(sid, repo, ref, identity_name, owner): + now = str(int(time.time())) + env_list = [ + {"name": "SESSION_ID", "value": sid}, + {"name": "REPO_URL", "value": repo}, + {"name": "REPO_REF", "value": ref}, + {"name": "HOME", "value": "/home/claude"}, + {"name": "WORKSPACE_DIR", "value": WORKSPACE_DIR}, + {"name": "TTYD_PORT", "value": str(TTYD_PORT)}, + {"name": "IDLE_TIMEOUT_SECONDS", "value": str(IDLE_TIMEOUT)}, + {"name": "AUTOSTART_CLAUDE", "value": AUTOSTART_CLAUDE}, + {"name": "VERIFY_COMMAND", "value": VERIFY_COMMAND}, + {"name": "AUTOPUSH_ENABLED", "value": str(bool(AUTOPUSH.get("enabled"))).lower()}, + {"name": "AUTOPUSH_BRANCH_PREFIX", "value": AUTOPUSH.get("branchPrefix", "claude/")}, + {"name": "AUTOPUSH_PROTECTED", "value": ",".join(AUTOPUSH.get("protectedBranches", []))}, + {"name": "EXTRA_APK_PACKAGES", "value": EXTRA_APK}, + {"name": "CLAUDE_CODE_VERSION", "value": CLAUDE_CODE_VERSION}, + {"name": "GIT_AUTHOR_NAME", "value": GIT_AUTHOR_NAME}, + {"name": "GIT_AUTHOR_EMAIL", "value": GIT_AUTHOR_EMAIL}, + {"name": "GIT_COMMITTER_NAME", "value": GIT_AUTHOR_NAME}, + {"name": "GIT_COMMITTER_EMAIL", "value": GIT_AUTHOR_EMAIL}, + ] + for key, val in ANTHROPIC_EXTRA_ENV.items(): + env_list.append({"name": str(key), "value": str(val)}) + + volumes = [ + {"name": "workspace", "emptyDir": {"sizeLimit": "4Gi"}}, + {"name": "home", "emptyDir": {"sizeLimit": "1Gi"}}, + {"name": "tmp", "emptyDir": {"medium": "Memory", "sizeLimit": "64Mi"}}, + {"name": "npm-cache", "emptyDir": {"sizeLimit": "1Gi"}}, + {"name": "bootstrap", "configMap": {"name": BOOTSTRAP_CM, "defaultMode": 0o555}}, + ] + mounts = [ + {"name": "workspace", "mountPath": WORKSPACE_DIR}, + {"name": "home", "mountPath": "/home/claude"}, + {"name": "tmp", "mountPath": "/tmp"}, + {"name": "npm-cache", "mountPath": "/home/claude/.npm"}, + {"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True}, + ] + + ident = GIT_IDENTITIES.get(identity_name) + if ident: + env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")}) + if ident.get("type") == "ssh": + volumes.append({ + "name": "git-identity", + "secret": { + "secretName": ident["secret"], + "defaultMode": 0o400, + "items": [ + {"key": ident.get("keyPrivateKey", "id"), "path": "id"}, + {"key": ident.get("keyKnownHosts", "known_hosts"), "path": "known_hosts"}, + ], + }, + }) + mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True}) + elif ident.get("type") == "https-token": + volumes.append({ + "name": "git-identity", + "secret": { + "secretName": ident["secret"], + "defaultMode": 0o400, + "items": [{"key": ident.get("keyToken", "token"), "path": "token"}], + }, + }) + mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True}) + env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")}) + + container = { + "name": "claude", + "image": SESSION_IMAGE, + "imagePullPolicy": SESSION_IMAGE_PULL_POLICY, + "command": ["/bin/sh", "/bootstrap/entrypoint.sh"], + "env": env_list, + "envFrom": [{"secretRef": {"name": ANTHROPIC_SECRET}}], + "ports": [{"name": "ttyd", "containerPort": TTYD_PORT}], + "volumeMounts": mounts, + "resources": SESSION_RESOURCES, + "securityContext": { + "allowPrivilegeEscalation": False, + "readOnlyRootFilesystem": True, + "runAsNonRoot": True, + "runAsUser": SESSION_RUN_UID, + "capabilities": {"drop": ["ALL"]}, + }, + "readinessProbe": { + "tcpSocket": {"port": TTYD_PORT}, + "initialDelaySeconds": 5, + "periodSeconds": 5, + "failureThreshold": 60, + }, + } + + labels = { + LABEL_APP: "claude-code", + LABEL_COMPONENT: "session", + "app.kubernetes.io/managed-by": "claude-code-manager", + LABEL_SID: sid, + } + labels.update({str(k): str(v) for k, v in POD_LABELS.items()}) + annotations = { + ANN_PREFIX + "repo": repo, + ANN_PREFIX + "ref": ref, + ANN_PREFIX + "git-identity": identity_name or "", + ANN_PREFIX + "owner": owner or "anon", + ANN_PREFIX + "created-at": now, + ANN_PREFIX + "last-activity": now, + } + annotations.update({str(k): str(v) for k, v in POD_ANNOTATIONS.items()}) + + spec = { + "serviceAccountName": SESSION_SA, + "automountServiceAccountToken": False, + "restartPolicy": "Never", + "activeDeadlineSeconds": MAX_LIFETIME, + "terminationGracePeriodSeconds": 30, + "enableServiceLinks": False, + "securityContext": { + "runAsNonRoot": True, + "runAsUser": SESSION_RUN_UID, + "runAsGroup": SESSION_RUN_UID, + "fsGroup": SESSION_RUN_UID, + "seccompProfile": {"type": "RuntimeDefault"}, + }, + "containers": [container], + "volumes": volumes, + } + if SESSION_IMAGE_PULL_SECRETS: + spec["imagePullSecrets"] = SESSION_IMAGE_PULL_SECRETS + if NODE_SELECTOR: + spec["nodeSelector"] = NODE_SELECTOR + if TOLERATIONS: + spec["tolerations"] = TOLERATIONS + if PRIORITY_CLASS: + spec["priorityClassName"] = PRIORITY_CLASS + + return { + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "claude-sess-" + sid, "namespace": SESSIONS_NS, + "labels": labels, "annotations": annotations}, + "spec": spec, + } + + +def build_service(sid): + return { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": "claude-sess-" + sid, + "namespace": SESSIONS_NS, + "labels": {LABEL_APP: "claude-code", LABEL_COMPONENT: "session", LABEL_SID: sid}, + }, + "spec": { + "selector": {LABEL_SID: sid}, + "ports": [{"name": "ttyd", "port": TTYD_PORT, "targetPort": TTYD_PORT}], + }, + } + + +def create_session(repo, ref, identity_name, owner): + repo = (repo or "").strip() + ref = (ref or "main").strip() + if not REPO_RE.match(repo): + return None, "リポジトリ URL の形式が不正です" + if not REF_RE.match(ref): + return None, "ref の形式が不正です" + host = repo_host(repo) + if not GIT_ALLOW_ARBITRARY and host not in GIT_ALLOWED_HOSTS: + return None, "許可されていない Git ホストです: " + str(host) + if identity_name and identity_name not in GIT_IDENTITIES: + return None, "未知の git identity: " + identity_name + if active_session_count() >= MAX_SESSIONS: + return None, "同時セッション数の上限(%d)に達しています" % MAX_SESSIONS + + sid = secrets.token_hex(5) + status, payload = k8s("POST", core("/pods"), build_pod(sid, repo, ref, identity_name, owner)) + if status not in (200, 201): + return None, "Pod 作成に失敗: %s" % payload.get("message", status) + status, payload = k8s("POST", core("/services"), build_service(sid)) + if status not in (200, 201): + k8s("DELETE", core("/pods/claude-sess-" + sid)) + return None, "Service 作成に失敗: %s" % payload.get("message", status) + log.info("session %s created repo=%s ref=%s owner=%s", sid, repo, ref, owner) + return sid, None + + +def delete_session(sid): + if not SID_RE.match(sid): + return False + k8s("DELETE", core("/pods/claude-sess-" + sid), {"gracePeriodSeconds": 10}) + k8s("DELETE", core("/services/claude-sess-" + sid)) + log.info("session %s deleted", sid) + return True + + +_touch_cache = {} + + +def touch_session(sid, min_interval=15): + if not SID_RE.match(sid): + return + now = time.time() + if now - _touch_cache.get(sid, 0) < min_interval: + return + _touch_cache[sid] = now + patch = {"metadata": {"annotations": {ANN_PREFIX + "last-activity": str(int(now))}}} + k8s("PATCH", core("/pods/claude-sess-" + sid), patch, + ctype="application/merge-patch+json") + +# -------------------------------------------------------------------------- +# 回収ループ +# -------------------------------------------------------------------------- +def reaper_loop(): + while True: + time.sleep(REAP_INTERVAL) + try: + reap_once() + except Exception as exc: # noqa: BLE001 - ループを止めない + log.exception("reaper error: %s", exc) + + +def reap_once(): + now = int(time.time()) + live_ids = set() + for s in list_sessions(): + sid = s["id"] + live_ids.add(sid) + reason = None + if s["phase"] in ("Succeeded", "Failed"): + reason = "phase=" + s["phase"] + else: + try: + created = int(s["createdAt"] or 0) + last = int(s["lastActivity"] or 0) + except ValueError: + created = last = now + if created and now - created > MAX_LIFETIME: + reason = "max-lifetime" + elif last and now - last > IDLE_TIMEOUT: + reason = "idle" + if reason: + log.info("reaping session %s (%s)", sid, reason) + delete_session(sid) + live_ids.discard(sid) + # 孤児 Service の掃除 + status, payload = k8s("GET", core("/services?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR))) + if status == 200: + for svc in payload.get("items", []): + sid = svc.get("metadata", {}).get("labels", {}).get(LABEL_SID, "") + if sid and sid not in live_ids: + log.info("reaping orphan service %s", sid) + k8s("DELETE", core("/services/claude-sess-" + sid)) + +# -------------------------------------------------------------------------- +# 認証(token モード) +# -------------------------------------------------------------------------- +def extract_token(handler): + auth = handler.headers.get("Authorization", "") + if auth.startswith("Bearer "): + return auth[7:].strip() + cookie = handler.headers.get("Cookie", "") + for part in cookie.split(";"): + part = part.strip() + if part.startswith("cc_token="): + return urllib.parse.unquote(part[9:]) + qs = urllib.parse.urlparse(handler.path).query + params = urllib.parse.parse_qs(qs) + if "token" in params: + return params["token"][0] + return "" + + +def token_ok(handler): + if AUTH_MODE != "token": + return True + if not AUTH_TOKEN: + return False + return hmac.compare_digest(extract_token(handler), AUTH_TOKEN) + +# -------------------------------------------------------------------------- +# HTTP ハンドラ +# -------------------------------------------------------------------------- +INDEX_HTML = """ + + +Claude Code — Remote Sessions + +

Claude Code — Remote Sessions

+ + + +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "claude-code-manager" + protocol_version = "HTTP/1.1" + + def _send(self, code, body=b"", ctype="application/json; charset=utf-8", extra=None): + if isinstance(body, str): + body = body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.send_header("X-Content-Type-Options", "nosniff") + for key, val in (extra or {}).items(): + self.send_header(key, val) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(body) + + def _json(self, code, obj, extra=None): + self._send(code, json.dumps(obj), "application/json; charset=utf-8", extra) + + def _body(self): + length = int(self.headers.get("Content-Length", "0") or "0") + if length <= 0: + return {} + try: + return json.loads(self.rfile.read(length) or b"{}") + except ValueError: + return {} + + def log_message(self, fmt, *args): + log.debug("%s - %s", self.address_string(), fmt % args) + + # ---- ルーティング ---- + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path == "/healthz": + return self._send(200, b'{"ok":true}') + if path == "/auth": + # nginx auth_request 用(token モードのみ意味を持つ) + if token_ok(self): + orig = self.headers.get("X-Original-URI", "") + m = re.search(r"/s/([a-f0-9]{10})/", orig) + if m: + try: + touch_session(m.group(1)) + except Exception: # noqa: BLE001 + pass + return self._send(200, b"ok", "text/plain") + return self._send(401, b"unauthorized", "text/plain") + if path == "/" or path == "/index.html": + return self._send(200, INDEX_HTML, "text/html; charset=utf-8") + if path == "/api/config": + if not token_ok(self): + return self._json(401, {"error": "unauthorized"}) + return self._json(200, { + "identities": sorted(GIT_IDENTITIES.keys()), + "idleTimeoutSeconds": IDLE_TIMEOUT, + "maxLifetimeSeconds": MAX_LIFETIME, + "maxSessions": MAX_SESSIONS, + "authMode": AUTH_MODE, + }) + if path == "/api/sessions": + if not token_ok(self): + return self._json(401, {"error": "unauthorized"}) + return self._json(200, {"sessions": list_sessions()}) + return self._send(404, b'{"error":"not found"}') + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + if path == "/login": + body = self._body() + token = str(body.get("token", "")) + if AUTH_MODE == "token" and AUTH_TOKEN and hmac.compare_digest(token, AUTH_TOKEN): + cookie = ("cc_token=" + urllib.parse.quote(token) + + "; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000") + return self._json(200, {"ok": True}, {"Set-Cookie": cookie}) + return self._json(401, {"error": "invalid token"}) + if not token_ok(self): + return self._json(401, {"error": "unauthorized"}) + if path == "/api/sessions": + body = self._body() + owner = self.headers.get("X-Forwarded-User", "") or "anon" + sid, err = create_session(body.get("repo", ""), body.get("ref", "main"), + body.get("identity", ""), owner) + if err: + return self._json(400, {"error": err}) + return self._json(201, {"id": sid, "url": "/s/" + sid + "/"}) + m = re.match(r"^/api/sessions/([a-f0-9]{10})/touch$", path) + if m: + touch_session(m.group(1), min_interval=0) + return self._json(200, {"ok": True}) + return self._send(404, b'{"error":"not found"}') + + def do_DELETE(self): + if not token_ok(self): + return self._json(401, {"error": "unauthorized"}) + m = re.match(r"^/api/sessions/([a-f0-9]{10})$", urllib.parse.urlparse(self.path).path) + if not m: + return self._send(404, b'{"error":"not found"}') + delete_session(m.group(1)) + return self._json(200, {"ok": True}) + + +def main(): + log.info("claude-code manager starting: ns=%s image=%s idle=%ds maxLife=%ds maxSessions=%d authMode=%s", + SESSIONS_NS, SESSION_IMAGE, IDLE_TIMEOUT, MAX_LIFETIME, MAX_SESSIONS, AUTH_MODE) + if AUTH_MODE == "token" and not AUTH_TOKEN: + log.warning("AUTH_MODE=token だがトークンが空。全リクエストを拒否します。") + threading.Thread(target=reaper_loop, name="reaper", daemon=True).start() + httpd = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler) + httpd.serve_forever() + + +if __name__ == "__main__": + import sys + if "--reap-once" in sys.argv[1:]: + # 予備の CronJob から呼ばれる一回きりの回収。manager 障害時の保険。 + log.info("one-shot reap starting (ns=%s)", SESSIONS_NS) + reap_once() + log.info("one-shot reap done") + else: + main() diff --git a/files/session-entrypoint.sh b/files/session-entrypoint.sh new file mode 100644 index 0000000..bf5e789 --- /dev/null +++ b/files/session-entrypoint.sh @@ -0,0 +1,230 @@ +#!/bin/sh +# ========================================================================== +# claude-code セッションブート(使い捨てコンテナ内で1回だけ実行) +# +# 1. git 認証(SSH 鍵 / HTTPS トークン)を $HOME にセットアップ +# 2. 許可済みリポジトリを $WORKSPACE_DIR へ clone +# 3. `claude` を PATH に用意(無ければ非 root で $HOME にインストール) +# 4. ttyd でブラウザにシェル(+ 任意で claude 自動起動)を提供 +# 5. アイドルウォッチドッグ:無通信が続いたら自身を終了 → Pod 破棄 +# +# 前提: 非 root(uid=10001) / readOnlyRootFilesystem。 +# 書き込み可能なのは $HOME, $WORKSPACE_DIR, /tmp のみ。 +# git / ttyd / node は事前ビルドイメージに同梱されている想定 +# (files/Dockerfile.session)。未同梱時は下記フォールバックが走る。 +# ========================================================================== +set -eu + +log() { printf '\033[36m[boot]\033[0m %s\n' "$*"; } +err() { printf '\033[31m[boot:ERROR]\033[0m %s\n' "$*" >&2; } + +: "${HOME:=/home/claude}" +: "${WORKSPACE_DIR:=/workspace}" +: "${TTYD_PORT:=7681}" +: "${IDLE_TIMEOUT_SECONDS:=1800}" +: "${AUTOSTART_CLAUDE:=true}" +: "${REPO_REF:=main}" +export HOME +export NPM_CONFIG_CACHE="$HOME/.npm" +export PATH="$HOME/.npm-global/bin:$HOME/.local/bin:$PATH" + +mkdir -p "$HOME/.ssh" "$WORKSPACE_DIR" "$HOME/.npm-global" +chmod 700 "$HOME/.ssh" + +# -------------------------------------------------------------------------- +# 1. git 認証セットアップ +# -------------------------------------------------------------------------- +git config --global user.name "${GIT_AUTHOR_NAME:-Claude}" +git config --global user.email "${GIT_AUTHOR_EMAIL:-claude@cafepieters.com}" +git config --global safe.directory '*' +git config --global advice.detachedHead false +git config --global credential.helper '' + +ASKPASS_DISABLE() { export GIT_TERMINAL_PROMPT=0; } +ASKPASS_DISABLE + +case "${GIT_IDENTITY_TYPE:-}" in + ssh) + if [ ! -r /git-identity/id ]; then err "SSH 鍵がマウントされていません"; exit 1; fi + install -m 600 /git-identity/id "$HOME/.ssh/id_session" + # known_hosts を必須にし、ホスト鍵の TOFU を無効化する + if [ -r /git-identity/known_hosts ]; then + install -m 644 /git-identity/known_hosts "$HOME/.ssh/known_hosts" + else + err "known_hosts がありません(StrictHostKeyChecking を維持するため必須)"; exit 1 + fi + cat > "$HOME/.ssh/config" < "$HOME/.git-credentials" </dev/null || git checkout -b "$REPO_REF" 2>/dev/null || true +log "作業ディレクトリ: $REPO_DIR ($(git rev-parse --short HEAD 2>/dev/null || echo '?'))" + +# -------------------------------------------------------------------------- +# 3. claude CLI を用意 +# -------------------------------------------------------------------------- +if ! command -v claude >/dev/null 2>&1; then + log "claude が未同梱のため $HOME にインストールします(初回は時間がかかります)" + npm config set prefix "$HOME/.npm-global" >/dev/null 2>&1 || true + _ver="${CLAUDE_CODE_VERSION:-}" + _pkg="@anthropic-ai/claude-code${_ver:+@$_ver}" + if ! npm install -g --no-fund --no-audit "$_pkg"; then + err "claude-code のインストールに失敗しました。事前ビルドイメージの利用を推奨します。" + exit 1 + fi +fi +log "claude: $(claude --version 2>/dev/null || echo 'unknown')" + +# 追加 apk(root 権限がある事前ビルドイメージのみ有効。非 root では警告のみ) +if [ -n "${EXTRA_APK_PACKAGES:-}" ]; then + if [ "$(id -u)" = "0" ]; then + apk add --no-cache $EXTRA_APK_PACKAGES || err "apk add に一部失敗" + else + err "EXTRA_APK_PACKAGES は非 root では無視されます: $EXTRA_APK_PACKAGES" + fi +fi + +# -------------------------------------------------------------------------- +# 4. 補助コマンド(claude-verify / claude-push) +# -------------------------------------------------------------------------- +mkdir -p "$HOME/.local/bin" + +cat > "$HOME/.local/bin/claude-verify" <<'EOF' +#!/bin/sh +# セッション設定の検証コマンドを実行する +if [ -z "${VERIFY_COMMAND:-}" ]; then echo "verifyCommand は未設定です"; exit 0; fi +echo "+ $VERIFY_COMMAND" +exec sh -c "$VERIFY_COMMAND" +EOF +chmod +x "$HOME/.local/bin/claude-verify" + +cat > "$HOME/.local/bin/claude-push" <<'EOF' +#!/bin/sh +# 検証 → 安全なブランチへ push(デフォルト/保護ブランチ・--force は禁止) +set -eu +if [ "${AUTOPUSH_ENABLED:-false}" != "true" ]; then + echo "autoPush は無効です(values: session.autoPush.enabled)。手動 push は 'git push' を使ってください。" >&2 + exit 1 +fi +cur="$(git rev-parse --abbrev-ref HEAD)" +prefix="${AUTOPUSH_BRANCH_PREFIX:-claude/}" +protected="${AUTOPUSH_PROTECTED:-main,master,develop,release}" +case ",$protected," in *",$cur,"*) + echo "現在のブランチ '$cur' は保護対象です。作業ブランチへ切り替えてください。" >&2; exit 1;; +esac +case "$cur" in "$prefix"*) target="$cur" ;; *) target="${prefix}${cur}" ;; esac +if [ -n "${VERIFY_COMMAND:-}" ]; then + echo "== verify: $VERIFY_COMMAND" + sh -c "$VERIFY_COMMAND" || { echo "検証に失敗したため push を中止します。" >&2; exit 1; } +fi +if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "${1:-chore: work from claude-code session $SESSION_ID}" +fi +[ "$target" = "$cur" ] || git branch -f "$target" "$cur" +echo "== push origin $target(--force なし)" +exec git push origin "HEAD:$target" +EOF +chmod +x "$HOME/.local/bin/claude-push" + +# -------------------------------------------------------------------------- +# 5. アイドルウォッチドッグ +# -------------------------------------------------------------------------- +idle_watchdog() { + # ttyd への ESTABLISHED 接続が IDLE_TIMEOUT_SECONDS の間ゼロなら Pod を終了させる + last_seen="$(date +%s)" + while true; do + sleep 30 + conns=0 + if command -v ss >/dev/null 2>&1; then + conns="$(ss -Htn "sport = :$TTYD_PORT" state established 2>/dev/null | wc -l | tr -d ' ')" + elif [ -r /proc/net/tcp ]; then + hexport="$(printf '%04X' "$TTYD_PORT")" + conns="$(awk -v p=":$hexport" '$2 ~ p"$" && $4=="01"{n++} END{print n+0}' /proc/net/tcp)" + fi + now="$(date +%s)" + if [ "${conns:-0}" -gt 0 ]; then + last_seen="$now" + elif [ $(( now - last_seen )) -ge "$IDLE_TIMEOUT_SECONDS" ]; then + err "アイドル ${IDLE_TIMEOUT_SECONDS}s を超過。セッションを終了します。" + kill -TERM "$TTYD_PID" 2>/dev/null || true + exit 0 + fi + done +} + +# -------------------------------------------------------------------------- +# 6. ttyd 起動 +# -------------------------------------------------------------------------- +if ! command -v ttyd >/dev/null 2>&1; then + err "ttyd が見つかりません。事前ビルドイメージ(files/Dockerfile.session)を利用してください。" + exit 1 +fi + +SHELL_CMD="/bin/bash" +command -v bash >/dev/null 2>&1 || SHELL_CMD="/bin/sh" + +cat > "$HOME/.session-shell" </dev/null 2>&1; then + exec claude +fi +exec $SHELL_CMD -l +EOF +chmod +x "$HOME/.session-shell" + +log "ttyd を 0.0.0.0:$TTYD_PORT で起動します" +ttyd --port "$TTYD_PORT" --interface 0.0.0.0 --writable \ + --client-option fontSize=14 --client-option 'theme={"background":"#1e1e1e"}' \ + --terminal-type xterm-256color \ + "$HOME/.session-shell" & +TTYD_PID=$! + +idle_watchdog & + +wait "$TTYD_PID" diff --git a/templates/NOTES.txt b/templates/NOTES.txt new file mode 100644 index 0000000..8b0cdc8 --- /dev/null +++ b/templates/NOTES.txt @@ -0,0 +1,57 @@ +claude-code ({{ .Chart.Version }}) をデプロイしました。 + +制御プレーン : {{ include "claude-code.fullname" . }} (namespace: {{ .Release.Namespace }}) +セッション NS : {{ include "claude-code.sessionsNamespace" . }} +セッション像 : {{ .Values.image.session.registry }}/{{ .Values.image.session.repository }}:{{ .Values.image.session.tag }} +認証モード : {{ .Values.auth.mode }} +アイドル破棄 : {{ .Values.session.idleTimeoutSeconds }}s / 最大寿命 {{ .Values.session.maxLifetimeSeconds }}s / 同時 {{ .Values.session.maxSessions }} + +アクセス方法: +{{- if .Values.ingress.enabled }} +{{- range .Values.ingress.hosts }} + https://{{ .host }}/ +{{- end }} +{{- else }} + kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "claude-code.fullname" . }} 8080:{{ .Values.service.port }} + → http://localhost:8080/ +{{- end }} + +{{- if eq .Values.auth.mode "token" }} + +アクセストークンの取得: + kubectl -n {{ .Release.Namespace }} get secret {{ include "claude-code.authSecretName" . }} \ + -o jsonpath='{.data.{{ .Values.auth.token.existingSecretKey | default "token" }}}' | base64 -d ; echo + + UI の「アクセストークン」欄に貼り付けるか、`?token=` を付けて開いてください。 +{{- else if eq .Values.auth.mode "basic" }} + +Basic 認証のユーザーでログインしてください。 +{{- else if eq .Values.auth.mode "forward" }} + +外部認証プロキシ({{ .Values.auth.forward.url }})経由でログインしてください。 +{{- else }} + +⚠ auth.mode=none — 認証なしで公開されています。信頼済みネットワーク限定で使用してください。 +{{- end }} + +{{- if and (not .Values.anthropic.existingSecret) (not .Values.anthropic.apiKey) (not .Values.anthropic.oauthToken) }} + +⚠ Claude Code の認証情報が未設定です(anthropic.apiKey / oauthToken / existingSecret のいずれも空)。 + セッション内の `claude` は認証に失敗します。`--set anthropic.apiKey=...` で再適用してください。 +{{- end }} +{{- if not .Values.git.identities }} + +⚠ git.identities が未設定です。公開リポジトリの clone のみ可能です。 + GitHub / Gitea へ push するには values に SSH 鍵か HTTPS トークンを登録してください(README 参照)。 +{{- end }} + +使い方: + 1. UI で「リポジトリ URL」「ブランチ」「認証 identity」を指定してセッション作成 + 2. 一覧の「開く」でブラウザ内ターミナル(Claude Code)に接続 + 3. セッション内で `claude-verify` / `claude-push` が利用可能 + 4. 無操作 {{ .Values.session.idleTimeoutSeconds }}s でセッション(Pod)は自動破棄されます + +許可された Git ホスト: {{ join ", " .Values.git.allowedHosts }} +{{- if .Values.git.allowArbitraryRepos }} + (allowArbitraryRepos=true のため任意ホストを許可) +{{- end }} diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl new file mode 100644 index 0000000..e3ed47c --- /dev/null +++ b/templates/_helpers.tpl @@ -0,0 +1,112 @@ +{{/* +========================================================================== +claude-code — 共通ヘルパー +========================================================================== +*/}} + +{{- define "claude-code.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "claude-code.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := include "claude-code.name" . -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "claude-code.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* 制御プレーン共通ラベル */}} +{{- define "claude-code.labels" -}} +helm.sh/chart: {{ include "claude-code.chart" . }} +{{ include "claude-code.selectorLabels" . }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: claude-code +{{- with .Values.commonLabels }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{- define "claude-code.selectorLabels" -}} +app.kubernetes.io/name: {{ include "claude-code.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{/* セッション Pod を作成するネームスペース */}} +{{- define "claude-code.sessionsNamespace" -}} +{{- default .Release.Namespace .Values.sessionsNamespace.name -}} +{{- end -}} + +{{/* 制御プレーン ServiceAccount 名 */}} +{{- define "claude-code.managerServiceAccountName" -}} +{{- printf "%s-manager" (include "claude-code.fullname" .) -}} +{{- end -}} + +{{/* セッション ServiceAccount 名 */}} +{{- define "claude-code.sessionServiceAccountName" -}} +{{- printf "%s-session" (include "claude-code.fullname" .) -}} +{{- end -}} + +{{/* 認証トークン Secret 名 */}} +{{- define "claude-code.authSecretName" -}} +{{- if .Values.auth.token.existingSecret -}} +{{- .Values.auth.token.existingSecret -}} +{{- else if .Values.auth.basic.existingSecret -}} +{{- .Values.auth.basic.existingSecret -}} +{{- else -}} +{{- printf "%s-auth" (include "claude-code.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{/* Anthropic 認証情報 Secret 名(セッション NS 側) */}} +{{- define "claude-code.anthropicSecretName" -}} +{{- if .Values.anthropic.existingSecret -}} +{{- .Values.anthropic.existingSecret -}} +{{- else -}} +{{- printf "%s-anthropic" (include "claude-code.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{/* Git アイデンティティ Secret 名(アイデンティティ名を与える) */}} +{{- define "claude-code.gitIdentitySecretName" -}} +{{- $ctx := .ctx -}} +{{- $name := .name -}} +{{- $id := index $ctx.Values.git.identities $name -}} +{{- if and $id $id.existingSecret -}} +{{- $id.existingSecret -}} +{{- else -}} +{{- printf "%s-git-%s" (include "claude-code.fullname" $ctx) $name -}} +{{- end -}} +{{- end -}} + +{{/* 自動生成トークン(未指定時)。リリース間で安定させるため lookup を優先。 */}} +{{- define "claude-code.resolvedToken" -}} +{{- if .Values.auth.token.value -}} +{{- .Values.auth.token.value -}} +{{- else -}} +{{- $sec := (lookup "v1" "Secret" .Release.Namespace (printf "%s-auth" (include "claude-code.fullname" .))) -}} +{{- if and $sec $sec.data (index $sec.data "token") -}} +{{- index $sec.data "token" | b64dec -}} +{{- else -}} +{{- randAlphaNum 40 -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* 制御プレーン Pod セキュリティコンテキスト */}} +{{- define "claude-code.podSecurityContext" -}} +{{- toYaml .Values.podSecurityContext -}} +{{- end -}} + +{{- define "claude-code.containerSecurityContext" -}} +{{- toYaml .Values.containerSecurityContext -}} +{{- end -}} diff --git a/templates/configmap-gateway.yaml b/templates/configmap-gateway.yaml new file mode 100644 index 0000000..6b853cb --- /dev/null +++ b/templates/configmap-gateway.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "claude-code.fullname" . }}-gateway + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +data: + gateway.conf.template: | +{{ .Files.Get "files/gateway.conf.template" | indent 4 }} + entrypoint.sh: | +{{ .Files.Get "files/gateway-entrypoint.sh" | indent 4 }} diff --git a/templates/configmap-manager.yaml b/templates/configmap-manager.yaml new file mode 100644 index 0000000..6c11e28 --- /dev/null +++ b/templates/configmap-manager.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "claude-code.fullname" . }}-manager + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +data: + manager.py: | +{{ .Files.Get "files/manager.py" | indent 4 }} diff --git a/templates/configmap-session.yaml b/templates/configmap-session.yaml new file mode 100644 index 0000000..d629f6c --- /dev/null +++ b/templates/configmap-session.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "claude-code.fullname" . }}-session + namespace: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +data: + entrypoint.sh: | +{{ .Files.Get "files/session-entrypoint.sh" | indent 4 }} diff --git a/templates/cronjob-reaper.yaml b/templates/cronjob-reaper.yaml new file mode 100644 index 0000000..185e26c --- /dev/null +++ b/templates/cronjob-reaper.yaml @@ -0,0 +1,71 @@ +{{- if .Values.session.reaper.cronjob.enabled }} +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "claude-code.fullname" . }}-reaper + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + app.kubernetes.io/component: reaper +spec: + schedule: {{ .Values.session.reaper.cronjob.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + startingDeadlineSeconds: 120 + jobTemplate: + spec: + activeDeadlineSeconds: 300 + backoffLimit: 1 + template: + metadata: + labels: + {{- include "claude-code.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: reaper + spec: + restartPolicy: Never + serviceAccountName: {{ include "claude-code.managerServiceAccountName" . }} + automountServiceAccountToken: true + securityContext: + {{- include "claude-code.podSecurityContext" . | nindent 12 }} + containers: + - name: reaper + image: "{{ .Values.image.manager.registry }}/{{ .Values.image.manager.repository }}:{{ .Values.image.manager.tag }}" + imagePullPolicy: {{ .Values.image.manager.pullPolicy }} + command: ["python3", "-u", "/app/manager.py", "--reap-once"] + env: + - name: PYTHONDONTWRITEBYTECODE + value: "1" + - name: LOG_LEVEL + value: {{ .Values.manager.logLevel | quote }} + - name: SESSIONS_NAMESPACE + value: {{ include "claude-code.sessionsNamespace" . | quote }} + - name: FULLNAME + value: {{ include "claude-code.fullname" . | quote }} + - name: IDLE_TIMEOUT_SECONDS + value: {{ .Values.session.idleTimeoutSeconds | quote }} + - name: MAX_LIFETIME_SECONDS + value: {{ .Values.session.maxLifetimeSeconds | quote }} + - name: TTYD_PORT + value: {{ .Values.session.ttydPort | quote }} + securityContext: + {{- include "claude-code.containerSecurityContext" . | nindent 16 }} + resources: + requests: { cpu: 20m, memory: 32Mi } + limits: { cpu: 200m, memory: 128Mi } + volumeMounts: + - name: manager-app + mountPath: /app + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: manager-app + configMap: + name: {{ include "claude-code.fullname" . }}-manager + defaultMode: 0555 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 8Mi +{{- end }} diff --git a/templates/deployment.yaml b/templates/deployment.yaml new file mode 100644 index 0000000..f0ce066 --- /dev/null +++ b/templates/deployment.yaml @@ -0,0 +1,255 @@ +{{- $sessNs := include "claude-code.sessionsNamespace" . -}} +{{- $ccVersion := .Values.session.claudeCodeVersion | default .Chart.AppVersion -}} +{{- /* --- git identities を manager が解釈できる JSON へ変換 --- */ -}} +{{- $idents := dict -}} +{{- range $name, $id := .Values.git.identities -}} + {{- $entry := dict "type" $id.type "secret" (include "claude-code.gitIdentitySecretName" (dict "ctx" $ "name" $name)) -}} + {{- if eq $id.type "ssh" -}} + {{- $_ := set $entry "keyPrivateKey" ($id.existingSecretKeyPrivateKey | default "id") -}} + {{- $_ := set $entry "keyKnownHosts" ($id.existingSecretKeyKnownHosts | default "known_hosts") -}} + {{- else if eq $id.type "https-token" -}} + {{- $_ := set $entry "keyToken" ($id.existingSecretKeyToken | default "token") -}} + {{- $_ := set $entry "username" ($id.username | default "x-access-token") -}} + {{- end -}} + {{- $_ := set $idents $name $entry -}} +{{- end -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "claude-code.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + app.kubernetes.io/component: control-plane +spec: + replicas: {{ .Values.gateway.replicaCount }} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 1 + maxSurge: 1 + selector: + matchLabels: + {{- include "claude-code.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: control-plane + template: + metadata: + labels: + {{- include "claude-code.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: control-plane + annotations: + checksum/manager: {{ .Files.Get "files/manager.py" | sha256sum }} + checksum/gateway: {{ print (.Files.Get "files/gateway.conf.template") (.Files.Get "files/gateway-entrypoint.sh") | sha256sum }} + checksum/session: {{ .Files.Get "files/session-entrypoint.sh" | sha256sum }} + {{- with .Values.commonAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "claude-code.managerServiceAccountName" . }} + automountServiceAccountToken: true + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + securityContext: + {{- include "claude-code.podSecurityContext" . | nindent 8 }} + containers: + # ---- gateway: 受付・認証・WebSocket プロキシ ---- + - name: gateway + image: "{{ .Values.image.gateway.registry }}/{{ .Values.image.gateway.repository }}:{{ .Values.image.gateway.tag }}" + imagePullPolicy: {{ .Values.image.gateway.pullPolicy }} + command: ["/bin/sh", "/etc/gateway/entrypoint.sh"] + env: + - name: GATEWAY_PORT + value: {{ .Values.gateway.port | quote }} + - name: MANAGER_PORT + value: "8081" + - name: TTYD_PORT + value: {{ .Values.session.ttydPort | quote }} + - name: SESSIONS_NS + value: {{ $sessNs | quote }} + - name: AUTH_MODE + value: {{ .Values.auth.mode | quote }} + {{- if eq .Values.auth.mode "forward" }} + - name: AUTH_FORWARD_URL + value: {{ required "auth.mode=forward には auth.forward.url が必須です" .Values.auth.forward.url | quote }} + {{- end }} + - name: NGINX_ERROR_LEVEL + value: {{ eq .Values.manager.logLevel "debug" | ternary "info" "warn" | quote }} + ports: + - name: http + containerPort: {{ .Values.gateway.port }} + securityContext: + {{- include "claude-code.containerSecurityContext" . | nindent 12 }} + readinessProbe: + httpGet: { path: /healthz, port: http } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /healthz, port: http } + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + {{- toYaml .Values.gateway.resources | nindent 12 }} + volumeMounts: + - name: gateway-config + mountPath: /etc/gateway + readOnly: true + - name: nginx-tmp + mountPath: /tmp/nginx + {{- if eq .Values.auth.mode "basic" }} + - name: gateway-secret + mountPath: /etc/gateway-secret + readOnly: true + {{- end }} + # ---- manager: ライフサイクル API / UI / 回収ループ ---- + - name: manager + image: "{{ .Values.image.manager.registry }}/{{ .Values.image.manager.repository }}:{{ .Values.image.manager.tag }}" + imagePullPolicy: {{ .Values.image.manager.pullPolicy }} + command: ["python3", "-u", "/app/manager.py"] + env: + - name: LISTEN_PORT + value: "8081" + - name: PYTHONDONTWRITEBYTECODE + value: "1" + - name: LOG_LEVEL + value: {{ .Values.manager.logLevel | quote }} + - name: FULLNAME + value: {{ include "claude-code.fullname" . | quote }} + - name: SESSIONS_NAMESPACE + value: {{ $sessNs | quote }} + - name: SESSION_SERVICE_ACCOUNT + value: {{ include "claude-code.sessionServiceAccountName" . | quote }} + - name: SESSION_IMAGE + value: "{{ .Values.image.session.registry }}/{{ .Values.image.session.repository }}:{{ .Values.image.session.tag }}" + - name: SESSION_IMAGE_PULL_POLICY + value: {{ .Values.image.session.pullPolicy | quote }} + {{- with .Values.imagePullSecrets }} + - name: SESSION_IMAGE_PULL_SECRETS_JSON + value: {{ toJson . | quote }} + {{- end }} + - name: SESSION_BOOTSTRAP_CONFIGMAP + value: {{ include "claude-code.fullname" . }}-session + - name: ANTHROPIC_SECRET + value: {{ include "claude-code.anthropicSecretName" . | quote }} + - name: GIT_IDENTITIES_JSON + value: {{ $idents | toJson | quote }} + - name: GIT_ALLOWED_HOSTS + value: {{ join "," .Values.git.allowedHosts | quote }} + - name: GIT_ALLOW_ARBITRARY + value: {{ .Values.git.allowArbitraryRepos | quote }} + - name: GIT_DEFAULT_AUTHOR_NAME + value: {{ .Values.git.defaultAuthorName | quote }} + - name: GIT_DEFAULT_AUTHOR_EMAIL + value: {{ .Values.git.defaultAuthorEmail | quote }} + - name: IDLE_TIMEOUT_SECONDS + value: {{ .Values.session.idleTimeoutSeconds | quote }} + - name: MAX_LIFETIME_SECONDS + value: {{ .Values.session.maxLifetimeSeconds | quote }} + - name: REAP_INTERVAL_SECONDS + value: {{ .Values.session.reaper.intervalSeconds | quote }} + - name: MAX_SESSIONS + value: {{ .Values.session.maxSessions | quote }} + - name: TTYD_PORT + value: {{ .Values.session.ttydPort | quote }} + - name: SESSION_RESOURCES_JSON + value: {{ toJson .Values.session.resources | quote }} + - name: WORKSPACE_DIR + value: {{ .Values.session.workspaceDir | quote }} + - name: AUTOSTART_CLAUDE + value: {{ .Values.session.autoStartClaude | quote }} + - name: VERIFY_COMMAND + value: {{ .Values.session.verifyCommand | quote }} + - name: AUTOPUSH_JSON + value: {{ toJson .Values.session.autoPush | quote }} + - name: EXTRA_APK_PACKAGES + value: {{ join " " .Values.session.extraApkPackages | quote }} + - name: CLAUDE_CODE_VERSION + value: {{ $ccVersion | quote }} + {{- with .Values.anthropic.extraEnv }} + - name: ANTHROPIC_EXTRA_ENV_JSON + value: {{ toJson . | quote }} + {{- end }} + {{- with .Values.session.podLabels }} + - name: SESSION_POD_LABELS_JSON + value: {{ toJson . | quote }} + {{- end }} + {{- with .Values.session.podAnnotations }} + - name: SESSION_POD_ANNOTATIONS_JSON + value: {{ toJson . | quote }} + {{- end }} + {{- with .Values.session.nodeSelector }} + - name: SESSION_NODE_SELECTOR_JSON + value: {{ toJson . | quote }} + {{- end }} + {{- with .Values.session.tolerations }} + - name: SESSION_TOLERATIONS_JSON + value: {{ toJson . | quote }} + {{- end }} + {{- with .Values.session.priorityClassName }} + - name: SESSION_PRIORITY_CLASS + value: {{ . | quote }} + {{- end }} + - name: AUTH_MODE + value: {{ .Values.auth.mode | quote }} + {{- if eq .Values.auth.mode "token" }} + - name: AUTH_TOKEN_FILE + value: /etc/manager-secret/token + {{- end }} + ports: + - name: manager + containerPort: 8081 + securityContext: + {{- include "claude-code.containerSecurityContext" . | nindent 12 }} + readinessProbe: + httpGet: { path: /healthz, port: manager } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /healthz, port: manager } + initialDelaySeconds: 10 + periodSeconds: 20 + resources: + {{- toYaml .Values.manager.resources | nindent 12 }} + volumeMounts: + - name: manager-app + mountPath: /app + readOnly: true + - name: manager-tmp + mountPath: /tmp + {{- if eq .Values.auth.mode "token" }} + - name: manager-secret + mountPath: /etc/manager-secret + readOnly: true + {{- end }} + volumes: + - name: gateway-config + configMap: + name: {{ include "claude-code.fullname" . }}-gateway + defaultMode: 0555 + - name: nginx-tmp + emptyDir: + medium: Memory + sizeLimit: 16Mi + - name: manager-app + configMap: + name: {{ include "claude-code.fullname" . }}-manager + defaultMode: 0555 + - name: manager-tmp + emptyDir: + medium: Memory + sizeLimit: 16Mi + {{- if eq .Values.auth.mode "token" }} + - name: manager-secret + secret: + secretName: {{ include "claude-code.authSecretName" . }} + items: + - key: {{ .Values.auth.token.existingSecretKey | default "token" }} + path: token + {{- end }} + {{- if eq .Values.auth.mode "basic" }} + - name: gateway-secret + secret: + secretName: {{ include "claude-code.authSecretName" . }} + items: + - key: {{ .Values.auth.basic.existingSecretKey | default "htpasswd" }} + path: htpasswd + {{- end }} diff --git a/templates/ingress.yaml b/templates/ingress.yaml new file mode 100644 index 0000000..52fe95b --- /dev/null +++ b/templates/ingress.yaml @@ -0,0 +1,40 @@ +{{- if .Values.ingress.enabled }} +{{- $fullName := include "claude-code.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + annotations: + {{- /* WebSocket を長時間維持できるよう read/send タイムアウトを既定で延長(明示指定があれば優先) */}} + {{- $defaults := dict + "nginx.ingress.kubernetes.io/proxy-read-timeout" "3600" + "nginx.ingress.kubernetes.io/proxy-send-timeout" "3600" }} + {{- toYaml (merge (deepCopy .Values.ingress.annotations) $defaults) | nindent 4 }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType | default "Prefix" }} + backend: + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- end }} + {{- end }} +{{- end }} diff --git a/templates/limitrange.yaml b/templates/limitrange.yaml new file mode 100644 index 0000000..6950a0b --- /dev/null +++ b/templates/limitrange.yaml @@ -0,0 +1,19 @@ +{{- if .Values.sessionsNamespace.limitRange.enabled }} +{{- $lr := .Values.sessionsNamespace.limitRange -}} +apiVersion: v1 +kind: LimitRange +metadata: + name: {{ include "claude-code.fullname" . }}-sessions + namespace: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +spec: + limits: + - type: Container + max: + {{- toYaml $lr.max | nindent 8 }} + default: + {{- toYaml $lr.default | nindent 8 }} + defaultRequest: + {{- toYaml $lr.defaultRequest | nindent 8 }} +{{- end }} diff --git a/templates/namespace-sessions.yaml b/templates/namespace-sessions.yaml new file mode 100644 index 0000000..1784e85 --- /dev/null +++ b/templates/namespace-sessions.yaml @@ -0,0 +1,19 @@ +{{- if and .Values.sessionsNamespace.create (ne (include "claude-code.sessionsNamespace" .) .Release.Namespace) }} +apiVersion: v1 +kind: Namespace +metadata: + name: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + {{- with .Values.sessionsNamespace.podSecurityLevel }} + pod-security.kubernetes.io/enforce: {{ . }} + pod-security.kubernetes.io/enforce-version: latest + pod-security.kubernetes.io/audit: {{ . }} + pod-security.kubernetes.io/warn: {{ . }} + {{- end }} + annotations: + "helm.sh/resource-policy": keep + {{- with .Values.commonAnnotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/templates/networkpolicy-manager.yaml b/templates/networkpolicy-manager.yaml new file mode 100644 index 0000000..8101adc --- /dev/null +++ b/templates/networkpolicy-manager.yaml @@ -0,0 +1,51 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "claude-code.fullname" . }}-manager + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "claude-code.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: control-plane + policyTypes: + - Ingress + - Egress + ingress: + # ブラウザ / Ingress コントローラーからの受付ポート + - ports: + - protocol: TCP + port: {{ .Values.gateway.port }} + egress: + # DNS + - to: + - namespaceSelector: + matchLabels: + {{- toYaml .Values.networkPolicy.dnsNamespaceSelector | nindent 14 }} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # kube-apiserver(セッション Pod/Service の CRUD) + - ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 6443 + # セッション Pod の ttyd へのリバースプロキシ + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ include "claude-code.sessionsNamespace" . }} + podSelector: + matchLabels: + app.kubernetes.io/name: claude-code + app.kubernetes.io/component: session + ports: + - protocol: TCP + port: {{ .Values.session.ttydPort }} +{{- end }} diff --git a/templates/networkpolicy-session.yaml b/templates/networkpolicy-session.yaml new file mode 100644 index 0000000..fc3fda2 --- /dev/null +++ b/templates/networkpolicy-session.yaml @@ -0,0 +1,75 @@ +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy -}} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "claude-code.fullname" . }}-session + namespace: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: claude-code + app.kubernetes.io/component: session + policyTypes: + - Ingress + - Egress + ingress: + # 制御プレーン(gateway)からの ttyd 接続のみ許可 + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ .Release.Namespace }} + podSelector: + matchLabels: + {{- include "claude-code.selectorLabels" . | nindent 14 }} + app.kubernetes.io/component: control-plane + ports: + - protocol: TCP + port: {{ .Values.session.ttydPort }} + egress: + # DNS + - to: + - namespaceSelector: + matchLabels: + {{- toYaml $np.dnsNamespaceSelector | nindent 14 }} + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- if $np.allowInternetHttps }} + # HTTPS(git over HTTPS + api.anthropic.com)。内部レンジは except で遮断。 + - to: + - ipBlock: + cidr: 0.0.0.0/0 + {{- with $np.blockedEgressCIDRs }} + except: + {{- toYaml . | nindent 14 }} + {{- end }} + ports: + - protocol: TCP + port: 443 + {{- end }} + {{- if $np.allowSshGit }} + # SSH git(port 22) + - to: + - ipBlock: + cidr: 0.0.0.0/0 + {{- with $np.blockedEgressCIDRs }} + except: + {{- toYaml . | nindent 14 }} + {{- end }} + ports: + - protocol: TCP + port: 22 + {{- end }} + {{- range $np.sessionEgressExtra }} + - to: + - ipBlock: + cidr: {{ .cidr }} + ports: + {{- toYaml .ports | nindent 8 }} + {{- end }} +{{- end }} diff --git a/templates/poddisruptionbudget.yaml b/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..002dcc1 --- /dev/null +++ b/templates/poddisruptionbudget.yaml @@ -0,0 +1,15 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "claude-code.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "claude-code.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: control-plane +{{- end }} diff --git a/templates/rbac.yaml b/templates/rbac.yaml new file mode 100644 index 0000000..17fa0a6 --- /dev/null +++ b/templates/rbac.yaml @@ -0,0 +1,40 @@ +{{- $sessNs := include "claude-code.sessionsNamespace" . -}} +--- +# 制御プレーンがセッション NS 内で必要とする最小権限。 +# ClusterRole は使わず、セッション NS に閉じた Role のみ。 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "claude-code.fullname" . }}-manager + namespace: {{ $sessNs }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "delete", "deletecollection"] + - apiGroups: [""] + resources: ["pods/log", "pods/status"] + verbs: ["get", "list"] + - apiGroups: [""] + resources: ["services"] + verbs: ["get", "list", "watch", "create", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "claude-code.fullname" . }}-manager + namespace: {{ $sessNs }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "claude-code.fullname" . }}-manager +subjects: + - kind: ServiceAccount + name: {{ include "claude-code.managerServiceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/templates/resourcequota.yaml b/templates/resourcequota.yaml new file mode 100644 index 0000000..6b5e83c --- /dev/null +++ b/templates/resourcequota.yaml @@ -0,0 +1,22 @@ +{{- if .Values.sessionsNamespace.resourceQuota.enabled }} +{{- $rq := .Values.sessionsNamespace.resourceQuota -}} +{{- $max := int .Values.session.maxSessions -}} +apiVersion: v1 +kind: ResourceQuota +metadata: + name: {{ include "claude-code.fullname" . }}-sessions + namespace: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +spec: + hard: + {{- if $rq.hard }} + {{- /* 明示指定された上限をそのまま使う */}} + {{- toYaml $rq.hard | nindent 4 }} + {{- else }} + {{- /* 既定は同時セッション数の抑制のみ。compute 集計上限は hard で明示指定する。 */}} + pods: {{ add $max 2 | quote }} + count/services: {{ add $max 2 | quote }} + count/pods: {{ add $max 2 | quote }} + {{- end }} +{{- end }} diff --git a/templates/secret-anthropic.yaml b/templates/secret-anthropic.yaml new file mode 100644 index 0000000..ebc523a --- /dev/null +++ b/templates/secret-anthropic.yaml @@ -0,0 +1,18 @@ +{{- if not .Values.anthropic.existingSecret }} +# Claude Code 認証情報。セッション NS に作成し、セッション Pod へ env として注入する。 +# 値が空のままインストールするとセッション内の claude が認証に失敗する(NOTES 参照)。 +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "claude-code.fullname" . }}-anthropic + namespace: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if eq .Values.anthropic.authMode "oauth" }} + CLAUDE_CODE_OAUTH_TOKEN: {{ .Values.anthropic.oauthToken | quote }} + {{- else }} + ANTHROPIC_API_KEY: {{ .Values.anthropic.apiKey | quote }} + {{- end }} +{{- end }} diff --git a/templates/secret-auth.yaml b/templates/secret-auth.yaml new file mode 100644 index 0000000..d0cd8ff --- /dev/null +++ b/templates/secret-auth.yaml @@ -0,0 +1,32 @@ +{{- if eq .Values.auth.mode "token" }} +{{- if not .Values.auth.token.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "claude-code.fullname" . }}-auth + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + annotations: + "helm.sh/resource-policy": keep +type: Opaque +stringData: + token: {{ include "claude-code.resolvedToken" . | quote }} +{{- end }} +{{- else if eq .Values.auth.mode "basic" }} +{{- if not .Values.auth.basic.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "claude-code.fullname" . }}-auth + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +type: Opaque +stringData: + htpasswd: | + {{- range .Values.auth.basic.users }} + {{ . }} + {{- end }} +{{- end }} +{{- end }} diff --git a/templates/secret-git.yaml b/templates/secret-git.yaml new file mode 100644 index 0000000..b13e4a9 --- /dev/null +++ b/templates/secret-git.yaml @@ -0,0 +1,30 @@ +{{- $ctx := . -}} +{{- $sessNs := include "claude-code.sessionsNamespace" . -}} +{{- range $name, $id := .Values.git.identities }} +{{- if not $id.existingSecret }} +--- +# Git アイデンティティ "{{ $name }}"(type: {{ $id.type }}) +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "claude-code.fullname" $ctx }}-git-{{ $name }} + namespace: {{ $sessNs }} + labels: + {{- include "claude-code.labels" $ctx | nindent 4 }} + claude-code.cafepieters.com/git-identity: {{ $name | quote }} +type: Opaque +stringData: + {{- if eq $id.type "ssh" }} + {{- if not $id.sshPrivateKey }}{{ fail (printf "git.identities.%s: type=ssh には sshPrivateKey か existingSecret が必要です" $name) }}{{- end }} + {{- if not $id.knownHosts }}{{ fail (printf "git.identities.%s: type=ssh には knownHosts が必須です(ホスト鍵の TOFU を無効化するため)" $name) }}{{- end }} + id: {{ $id.sshPrivateKey | quote }} + known_hosts: {{ $id.knownHosts | quote }} + {{- else if eq $id.type "https-token" }} + {{- if not $id.token }}{{ fail (printf "git.identities.%s: type=https-token には token か existingSecret が必要です" $name) }}{{- end }} + username: {{ $id.username | default "x-access-token" | quote }} + token: {{ $id.token | quote }} + {{- else }} + {{ fail (printf "git.identities.%s: type は ssh か https-token を指定してください" $name) }} + {{- end }} +{{- end }} +{{- end }} diff --git a/templates/service.yaml b/templates/service.yaml new file mode 100644 index 0000000..6cc8683 --- /dev/null +++ b/templates/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "claude-code.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + app.kubernetes.io/component: control-plane +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "claude-code.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: control-plane diff --git a/templates/serviceaccount.yaml b/templates/serviceaccount.yaml new file mode 100644 index 0000000..72b00fa --- /dev/null +++ b/templates/serviceaccount.yaml @@ -0,0 +1,22 @@ +--- +# 制御プレーン(manager / reaper)用 ServiceAccount。 +# セッション NS 内で Pod / Service を作成・削除する権限のみを持つ(rbac.yaml)。 +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "claude-code.managerServiceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +automountServiceAccountToken: true +--- +# セッション Pod 用 ServiceAccount。 +# いかなる RBAC も紐づけず、トークンも配布しない(API サーバーへ到達不能)。 +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "claude-code.sessionServiceAccountName" . }} + namespace: {{ include "claude-code.sessionsNamespace" . }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +automountServiceAccountToken: false diff --git a/values.schema.json b/values.schema.json new file mode 100644 index 0000000..d06445c --- /dev/null +++ b/values.schema.json @@ -0,0 +1,269 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "title": "claude-code Helm chart values", + "type": "object", + "additionalProperties": true, + "properties": { + "nameOverride": { "type": "string" }, + "fullnameOverride": { "type": "string" }, + + "image": { + "type": "object", + "additionalProperties": false, + "required": ["gateway", "manager", "session"], + "properties": { + "gateway": { "$ref": "#/definitions/image" }, + "manager": { "$ref": "#/definitions/image" }, + "session": { "$ref": "#/definitions/image" } + } + }, + "imagePullSecrets": { + "type": "array", + "items": { "type": "object", "required": ["name"], "properties": { "name": { "type": "string" } } } + }, + + "gateway": { + "type": "object", + "additionalProperties": false, + "properties": { + "replicaCount": { "type": "integer", "minimum": 1 }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "resources": { "type": "object" } + } + }, + + "manager": { + "type": "object", + "additionalProperties": false, + "properties": { + "logLevel": { "type": "string", "enum": ["debug", "info", "warning", "error"] }, + "resources": { "type": "object" } + } + }, + + "sessionsNamespace": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "create": { "type": "boolean" }, + "podSecurityLevel": { "type": "string", "enum": ["privileged", "baseline", "restricted"] }, + "resourceQuota": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "hard": { "type": "object" } + } + }, + "limitRange": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "max": { "type": "object" }, + "default": { "type": "object" }, + "defaultRequest": { "type": "object" } + } + } + } + }, + + "auth": { + "type": "object", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { "type": "string", "enum": ["token", "basic", "forward", "none"] }, + "token": { + "type": "object", + "additionalProperties": false, + "properties": { + "value": { "type": "string" }, + "existingSecret": { "type": "string" }, + "existingSecretKey": { "type": "string" } + } + }, + "basic": { + "type": "object", + "additionalProperties": false, + "properties": { + "users": { "type": "array", "items": { "type": "string" } }, + "existingSecret": { "type": "string" }, + "existingSecretKey": { "type": "string" } + } + }, + "forward": { + "type": "object", + "additionalProperties": false, + "properties": { + "url": { "type": "string" }, + "signInUrl": { "type": "string" } + } + } + } + }, + + "anthropic": { + "type": "object", + "additionalProperties": false, + "required": ["authMode"], + "properties": { + "authMode": { "type": "string", "enum": ["apiKey", "oauth"] }, + "apiKey": { "type": "string" }, + "oauthToken": { "type": "string" }, + "existingSecret": { "type": "string" }, + "existingSecretKeyApiKey": { "type": "string" }, + "existingSecretKeyOauthToken": { "type": "string" }, + "extraEnv": { "type": "object" } + } + }, + + "git": { + "type": "object", + "additionalProperties": false, + "required": ["allowedHosts"], + "properties": { + "allowedHosts": { "type": "array", "items": { "type": "string" } }, + "allowArbitraryRepos": { "type": "boolean" }, + "defaultAuthorName": { "type": "string" }, + "defaultAuthorEmail": { "type": "string" }, + "identities": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["type"], + "properties": { + "type": { "type": "string", "enum": ["ssh", "https-token"] }, + "sshPrivateKey": { "type": "string" }, + "knownHosts": { "type": "string" }, + "username": { "type": "string" }, + "token": { "type": "string" }, + "existingSecret": { "type": "string" }, + "existingSecretKeyPrivateKey": { "type": "string" }, + "existingSecretKeyKnownHosts": { "type": "string" }, + "existingSecretKeyToken": { "type": "string" } + } + } + } + } + }, + + "session": { + "type": "object", + "additionalProperties": false, + "properties": { + "autoStartClaude": { "type": "boolean" }, + "workspaceDir": { "type": "string" }, + "ttydPort": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "extraApkPackages": { "type": "array", "items": { "type": "string" } }, + "claudeCodeVersion": { "type": "string" }, + "resources": { "type": "object" }, + "idleTimeoutSeconds": { "type": "integer", "minimum": 60 }, + "maxLifetimeSeconds": { "type": "integer", "minimum": 300 }, + "reaper": { + "type": "object", + "additionalProperties": false, + "properties": { + "intervalSeconds": { "type": "integer", "minimum": 10 }, + "cronjob": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "schedule": { "type": "string" } + } + } + } + }, + "maxSessions": { "type": "integer", "minimum": 1 }, + "verifyCommand": { "type": "string" }, + "autoPush": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "branchPrefix": { "type": "string" }, + "protectedBranches": { "type": "array", "items": { "type": "string" } } + } + }, + "podLabels": { "type": "object" }, + "podAnnotations": { "type": "object" }, + "nodeSelector": { "type": "object" }, + "tolerations": { "type": "array" }, + "priorityClassName": { "type": "string" } + } + }, + + "networkPolicy": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "dnsNamespaceSelector": { "type": "object" }, + "allowInternetHttps": { "type": "boolean" }, + "allowSshGit": { "type": "boolean" }, + "blockedEgressCIDRs": { "type": "array", "items": { "type": "string" } }, + "sessionEgressExtra": { + "type": "array", + "items": { + "type": "object", + "required": ["cidr", "ports"], + "properties": { + "cidr": { "type": "string" }, + "ports": { "type": "array" } + } + } + } + } + }, + + "ingress": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "className": { "type": "string" }, + "annotations": { "type": "object" }, + "hosts": { "type": "array" }, + "tls": { "type": "array" } + } + }, + + "service": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + } + }, + + "podSecurityContext": { "type": "object" }, + "containerSecurityContext": { "type": "object" }, + "commonLabels": { "type": "object" }, + "commonAnnotations": { "type": "object" }, + "podDisruptionBudget": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "minAvailable": { "type": ["integer", "string"] } + } + } + }, + "definitions": { + "image": { + "type": "object", + "additionalProperties": false, + "required": ["registry", "repository", "tag"], + "properties": { + "registry": { "type": "string" }, + "repository": { "type": "string" }, + "tag": { "type": "string" }, + "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] } + } + } + } +} diff --git a/values.yaml b/values.yaml new file mode 100644 index 0000000..911df6a --- /dev/null +++ b/values.yaml @@ -0,0 +1,338 @@ +# ========================================================================== +# claude-code — Claude Code リモート実行 Helm チャート +# -------------------------------------------------------------------------- +# 構成: +# gateway ... 受付 nginx。ブラウザ ↔ セッションの単一入口。認証・WebSocket +# プロキシ・アクティビティ計測を担う。 +# manager ... 制御プレーン(Python 標準ライブラリのみ)。セッションの +# 作成/一覧/破棄 API、Web UI、アイドル回収ループ。 +# session ... セッションごとに作られる使い捨て Pod。Alpine ベースで +# git clone → 編集 → 検証 → push を行い、claude を ttyd 経由で +# ブラウザに提供する。アイドルで自動破棄。 +# +# セキュリティ上の注意(必読): +# - この values.yaml に平文の API キー / SSH 秘密鍵 / トークンを書かない。 +# Git にコミットしない。実値は CI の --set / --set-file か existingSecret で注入する。 +# - セッション Pod は API サーバーへアクセスできない(SA トークン非マウント)。 +# - セッション Pod の egress は Git ホストと api.anthropic.com のみに制限される +# (networkPolicy.enabled: true のとき)。 +# - 詳細は README.md「セキュリティモデル」を参照。 +# ========================================================================== + +nameOverride: "" +fullnameOverride: "" + +# -------------------------------------------------------------------------- +# image: すべて Alpine ベース / linux/arm64 対応イメージ +# -------------------------------------------------------------------------- +image: + # 受付 nginx(非 root 版) + gateway: + registry: docker.io + repository: nginxinc/nginx-unprivileged + tag: "1.29.1-alpine" + pullPolicy: IfNotPresent + # 制御プレーン(Python 標準ライブラリのみ・追加 pip 不要) + manager: + registry: docker.io + repository: library/python + tag: "3.13-alpine" + pullPolicy: IfNotPresent + # セッションの使い捨てコンテナ。 + # 既定は CI(session-image.yaml)がビルドする事前ビルドイメージ + # (git + openssh + ttyd + node + @anthropic-ai/claude-code 同梱、非 root 実行)。 + # restricted PodSecurity / readOnlyRootFilesystem 下で確実に動くのはこの方式。 + # + # 事前ビルドを使わず素の node:alpine 等を指定することも可能だが、その場合 + # ブート時に $HOME へ claude を npm インストールし、ttyd が同梱されていないと + # 起動に失敗する(apk は非 root では不可)。フォールバック例: + # registry: docker.io / repository: library/node / tag: "22-alpine" + session: + registry: git.cafepieters.com + repository: helmchart/claude-code/session + tag: "2.1.259" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# -------------------------------------------------------------------------- +# gateway: 受付・ルーティング +# -------------------------------------------------------------------------- +gateway: + replicaCount: 1 + # gateway コンテナの待受ポート(Service / Ingress のバックエンド) + port: 8080 + resources: + requests: + cpu: 25m + memory: 32Mi + limits: + cpu: 250m + memory: 128Mi + +# -------------------------------------------------------------------------- +# manager: 制御プレーン +# -------------------------------------------------------------------------- +manager: + # debug | info | warning | error + logLevel: info + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + +# -------------------------------------------------------------------------- +# sessionsNamespace: セッション Pod を隔離するネームスペース +# -------------------------------------------------------------------------- +sessionsNamespace: + # 空 = リリース先ネームスペースを使う。セキュリティ上、専用ネームスペースを推奨。 + name: "claude-code-sessions" + # true ならこのチャートがネームスペースを作成し PodSecurity ラベルを付与する。 + # 既存ネームスペースを使う場合は false にし、下記ラベルを手動で付与すること。 + create: true + # PodSecurity admission レベル(restricted 準拠のセッション Pod を強制) + podSecurityLevel: restricted + + # セッション NS 全体の使用量上限(暴走・過剰生成のブラスト半径を限定) + resourceQuota: + enabled: true + # 省略値は session.maxSessions と session.resources から算出される。 + # 明示指定するとそのまま ResourceQuota.spec.hard になる。 + hard: {} + # requests.cpu: "4" + # requests.memory: 8Gi + # limits.cpu: "16" + # limits.memory: 32Gi + + # セッション NS のコンテナ既定値 / 上限 + limitRange: + enabled: true + max: + cpu: "4" + memory: 4Gi + default: + cpu: "1" + memory: 1Gi + defaultRequest: + cpu: 100m + memory: 256Mi + +# -------------------------------------------------------------------------- +# auth: ブラウザからのアクセス認証 +# -------------------------------------------------------------------------- +auth: + # token : Bearer トークン / Cookie / ?token= で検証(既定) + # basic : HTTP Basic 認証(htpasswd) + # forward : 外部認証プロキシ(oauth2-proxy 等)へ auth_request を委譲 + # none : 認証なし(信頼済みネットワーク限定・非推奨) + mode: token + + token: + # 空なら install 時に自動生成される(helm get manifest で参照可能なため、 + # 運用では existingSecret を使うのが望ましい)。 + value: "" + existingSecret: "" + existingSecretKey: "token" + + basic: + # `htpasswd -nbB user pass` の出力を1行以上。existingSecret 優先。 + users: [] + existingSecret: "" + existingSecretKey: "htpasswd" + + forward: + # nginx auth_request の転送先(例: http://oauth2-proxy.auth.svc.cluster.local:4180/oauth2/auth) + url: "" + # 未認証時のリダイレクト先(任意) + signInUrl: "" + +# -------------------------------------------------------------------------- +# anthropic: Claude Code の認証情報 +# -------------------------------------------------------------------------- +anthropic: + # apiKey : ANTHROPIC_API_KEY を使う + # oauth : CLAUDE_CODE_OAUTH_TOKEN を使う(`claude setup-token` で発行) + authMode: apiKey + apiKey: "" + oauthToken: "" + # 上記の代わりに既存 Secret を参照 + existingSecret: "" + existingSecretKeyApiKey: "ANTHROPIC_API_KEY" + existingSecretKeyOauthToken: "CLAUDE_CODE_OAUTH_TOKEN" + # 全セッションへ渡す追加環境変数(例: ANTHROPIC_BASE_URL / ANTHROPIC_MODEL) + extraEnv: {} + +# -------------------------------------------------------------------------- +# git: 接続先ホストと認証アイデンティティ +# -------------------------------------------------------------------------- +git: + # チェックアウトを許可するホスト allow-list。manager はこれ以外のホストを拒否する。 + allowedHosts: + - github.com + - gitlab.com + - 192.168.9.65 + - git.cafepieters.com + # allow-list を無効化し任意 URL を許可する(非推奨) + allowArbitraryRepos: false + + # コミット時の既定 author(セッション作成時に上書き可) + defaultAuthorName: "Claude" + defaultAuthorEmail: "claude@cafepieters.com" + + # 認証アイデンティティ。セッション作成時に名前で選択する。 + # GitHub / Gitea / GitLab など SSH または HTTPS トークンで認証する任意のホストに対応。 + identities: {} + # gitea-ssh: + # type: ssh # ssh | https-token + # # --- ssh のとき --- + # sshPrivateKey: "" # PEM 形式。existingSecret 優先 + # knownHosts: "" # `ssh-keyscan ` の出力(必須/ホスト鍵TOFUを無効化) + # # --- 既存 Secret を使う場合 --- + # existingSecret: "" + # existingSecretKeyPrivateKey: "id" + # existingSecretKeyKnownHosts: "known_hosts" + # github-https: + # type: https-token + # username: "x-access-token" # GitHub は任意文字列、Gitea はユーザー名 + # token: "" # PAT。existingSecret 優先 + # existingSecret: "" + # existingSecretKeyToken: "token" + +# -------------------------------------------------------------------------- +# session: 使い捨てセッションの挙動とライフサイクル +# -------------------------------------------------------------------------- +session: + # ブート完了後に `claude` を自動起動する(false ならログインシェルで待受) + autoStartClaude: true + # リポジトリを clone する作業ディレクトリ + workspaceDir: /workspace + # ttyd の待受ポート(Pod 内からのみ到達可能) + ttydPort: 7681 + # ブート時に追加インストールする apk パッケージ(言語ランタイム等) + extraApkPackages: [] + # インストールする @anthropic-ai/claude-code のバージョン(空 = チャート appVersion) + claudeCodeVersion: "" + + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: "2" + memory: 2Gi + ephemeral-storage: 4Gi + + # ---- lifecycle: 破棄条件 ---- + # 無通信がこの秒数継続したセッションを破棄する(Pod 内ウォッチドッグ + manager 回収) + idleTimeoutSeconds: 1800 + # 生成からの最大寿命(Pod の activeDeadlineSeconds)。到達で強制終了。 + maxLifetimeSeconds: 43200 + + reaper: + # manager 内アイドル回収ループの実行間隔 + intervalSeconds: 60 + # 予備の孤児掃除 CronJob(manager 障害時の保険) + cronjob: + enabled: true + schedule: "*/15 * * * *" + + # 同時に存在できるセッション数の上限(manager が enforce) + maxSessions: 10 + + # ---- verify / push ---- + # セッション内の `claude-verify` が実行するコマンド(例: "npm test" / "make check") + verifyCommand: "" + autoPush: + # true でもデフォルトブランチや protectedBranches への push、--force は禁止。 + enabled: false + branchPrefix: "claude/" + protectedBranches: + - main + - master + - develop + - release + + # セッション Pod への追加メタデータ / スケジューリング + podLabels: {} + podAnnotations: {} + nodeSelector: {} + tolerations: [] + # セッション Pod の優先度クラス(任意) + priorityClassName: "" + +# -------------------------------------------------------------------------- +# networkPolicy: 通信制限(CNI が NetworkPolicy 対応であること) +# -------------------------------------------------------------------------- +networkPolicy: + enabled: true + # DNS 解決を許可する kube-dns の namespace セレクタ + dnsNamespaceSelector: + kubernetes.io/metadata.name: kube-system + # api.anthropic.com / GitHub は IP 固定できないため、既定でセッションの + # 443/tcp(HTTPS git・Claude API)と 22/tcp(SSH git)の外向きを許可する。 + # 完全に閉じる場合は egress プロキシを用意し false にする。 + allowInternetHttps: true + allowSshGit: true + # allowInternetHttps 時に egress から除外する CIDR(SSRF 対策)。 + # クラウドのメタデータ IP は既定で遮断。自クラスターの Pod/Service CIDR を + # 追加すると、セッションから内部サービスへの到達を防げる。 + blockedEgressCIDRs: + - 169.254.169.254/32 + # 追加で許可する egress(社内ミラー / プロキシ用) + sessionEgressExtra: [] + # - cidr: 10.0.0.0/8 + # ports: + # - protocol: TCP + # port: 443 + +# -------------------------------------------------------------------------- +# ingress / service +# -------------------------------------------------------------------------- +ingress: + enabled: false + className: "" + annotations: {} + # nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + # nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + hosts: + - host: claude-code.example.com + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: claude-code-tls + # hosts: + # - claude-code.example.com + +service: + type: ClusterIP + port: 80 + +# -------------------------------------------------------------------------- +# 制御プレーンの Pod / コンテナセキュリティ +# -------------------------------------------------------------------------- +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +containerSecurityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +# 全リソースへ付与する追加ラベル / アノテーション +commonLabels: {} +commonAnnotations: {} + +podDisruptionBudget: + enabled: false + minAvailable: 1