feat: Alpine ベースの Claude Code リモート実行 Helm チャート初版 (v2.1.259)
Helm Chart Release / release-chart (push) Successful in 12s
Build Session Image / build-push (push) Successful in 2m15s

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 検証済み
This commit is contained in:
2026-09-03 08:10:12 +09:00
parent 457a81a17f
commit 1bd0fec23a
39 changed files with 3745 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
# Helm チャートのテキストは常に LF(CRLF はブロックスカラー等で問題を起こしうる)
* text=auto eol=lf
*.png binary
*.tgz binary
+131
View File
@@ -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 }}"
@@ -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 }}"
+76
View File
@@ -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"
+21
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
# CHANGELOG 2.1.259 — 初版
claude-code Helm チャートの新規作成。
## 追加
- **制御プレーン(Deployment)**
- `gateway`(nginx-unprivileged / Alpine): 単一入口。`auth.mode` に応じて
token / basic / forward / none の認証、`/s/<id>/` のセッションへの 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 最新版に一致)。
+92
View File
@@ -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:<appVersion>`(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:<appVersion>` + `latest`)へ push。
- **手動リリース(臨時)**: 改修作業などで必要な場合はバージョン末尾にアルファベットを付与する
(例 `2.1.259` → `2.1.259-a` → `2.1.259-b`)。対応する `CHANGELOG-<version>.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 を実装。
**対象ファイル**: 全ファイル(初期コミット)
+32
View File
@@ -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"
+369 -1
View File
@@ -1,3 +1,371 @@
# claude-code # claude-code
Alpine based container designed for running Claude Code remotely. **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:<appVersion>`
---
## 目次
- [アーキテクチャ](#アーキテクチャ)
- [セッションのライフサイクル](#セッションのライフサイクル)
- [セキュリティモデル](#セキュリティモデル)
- [インストール](#インストール)
- [使い方](#使い方)
- [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/<id>/` をセッション 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/<id>/` 経由でブラウザ内ターミナルに接続。`claude-verify` / `claude-push` が使える
4. **破棄** — 次のいずれかで Pod ごと消滅(Service も回収)
- **アイドル**: `ttyd` への接続が `session.idleTimeoutSeconds` 秒間ゼロ → Pod 内ウォッチドッグが自壊
- **アイドル(保険)**: manager の回収ループが `last-activity` アノテーションを見て削除
- **最大寿命**: `session.maxLifetimeSeconds`(Pod の `activeDeadlineSeconds`)到達で強制終了
- **手動**: UI の「破棄」/ `DELETE /api/sessions/<id>`
- **孤児掃除**: 予備 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/<branch>` へ 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/<id>/`(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.<name>.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:<appVersion>` です。
`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 <sessionsNamespace> get pods -l app.kubernetes.io/component=session`
- **セッションのログ**: `kubectl -n <sessionsNamespace> logs claude-sess-<id>`
- **強制破棄**: UI から、または `kubectl -n <sessionsNamespace> delete pod,svc claude-sess-<id>`
- **回収の確認**: manager のログに `reaping session <id> (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 <host>` で取り直す |
| clone が `terminated ... could not read Username` | HTTPS の場合 `git.identities.<name>` 未指定 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<ver>` → Gitea リリース → チャート publish。
- `nginx` / `python` の Alpine タグのみ更新の場合は `values.yaml` を更新してコミットするだけ
(**リリースなし**)。
- **手動リリース(臨時)** — バージョン末尾にアルファベットを付与(`2.1.259` → `2.1.259-a`)。
対応する `CHANGELOG-<version>.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 |
+69
View File
@@ -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
+26
View File
@@ -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
+41
View File
@@ -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
+37
View File
@@ -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"]
+68
View File
@@ -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;'
+100
View File
@@ -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/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$ {
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;
}
}
}
+685
View File
@@ -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<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|ssh://git@(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|git@(?P<h3>[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 = """<!doctype html>
<html lang="ja"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Claude Code — Remote Sessions</title>
<style>
:root{color-scheme:light dark}
*{box-sizing:border-box}
body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;margin:0;padding:1rem;max-width:760px;margin-inline:auto}
h1{font-size:1.15rem;margin:.2rem 0 1rem}
form,.card{border:1px solid #8884;border-radius:10px;padding:1rem;margin-bottom:1rem}
label{display:block;font-weight:600;margin:.5rem 0 .2rem}
input,select,button{font:inherit;padding:.55rem .6rem;border-radius:8px;border:1px solid #8886;width:100%}
button{background:#4338ca;color:#fff;border:0;font-weight:600;cursor:pointer;margin-top:.8rem}
button.sec{background:#8883;color:inherit}
.row{display:flex;gap:.5rem;align-items:center}
.row>*{width:auto}
table{width:100%;border-collapse:collapse;font-size:.92rem}
td,th{text-align:left;padding:.45rem .3rem;border-bottom:1px solid #8883;vertical-align:top}
a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none}
.muted{opacity:.65;font-size:.85rem}
code{background:#8882;padding:.1rem .3rem;border-radius:4px}
</style></head><body>
<h1>Claude Code — Remote Sessions</h1>
<div id="login" hidden>
<form onsubmit="return doLogin(event)">
<label for="tk">アクセストークン</label>
<input id="tk" type="password" autocomplete="current-password" placeholder="Bearer token">
<button type="submit">ログイン</button>
</form>
</div>
<div id="app" hidden>
<form onsubmit="return doCreate(event)">
<label for="repo">リポジトリ URL</label>
<input id="repo" placeholder="https://github.com/owner/repo.git または git@host:owner/repo.git" required>
<div class="row">
<div style="flex:1"><label for="ref">ブランチ / タグ</label><input id="ref" value="main"></div>
<div style="flex:1"><label for="ident">認証 identity</label><select id="ident"></select></div>
</div>
<button type="submit">セッション作成</button>
</form>
<div class="card">
<div class="row" style="justify-content:space-between">
<strong>セッション一覧</strong>
<button class="sec" style="width:auto;margin:0" onclick="refresh()">更新</button>
</div>
<table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead>
<tbody id="rows"></tbody></table>
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。</p>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
let identities=[];
function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);}
async function boot(){
const r=await api('/api/config');
if(r.status===401){$('#login').hidden=false;return;}
const c=await r.json();
identities=c.identities||[];
$('#idle').textContent=c.idleTimeoutSeconds;
const sel=$('#ident');
sel.innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+identities.map(i=>`<option>${i}</option>`).join('');
$('#app').hidden=false;
refresh();
}
async function doLogin(e){e.preventDefault();
const r=await api('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:$('#tk').value})});
if(r.ok){location.reload();}else{alert('トークンが違います');}
return false;
}
async function doCreate(e){e.preventDefault();
const body={repo:$('#repo').value.trim(),ref:$('#ref').value.trim()||'main',identity:$('#ident').value};
const r=await api('/api/sessions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
if(!r.ok){alert(j.error||'作成に失敗しました');return false;}
$('#repo').value='';refresh();return false;
}
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
await api('/api/sessions/'+id,{method:'DELETE'});refresh();}
async function refresh(){
const r=await api('/api/sessions');if(!r.ok)return;
const list=(await r.json()).sessions||[];
$('#rows').innerHTML=list.map(s=>`<tr>
<td><code>${s.id}</code></td>
<td>${s.phase}</td>
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td>
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a>
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td>
</tr>`).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
}
boot();
</script></body></html>
"""
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()
+230
View File
@@ -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" <<EOF
Host *
IdentityFile $HOME/.ssh/id_session
IdentitiesOnly yes
StrictHostKeyChecking yes
UserKnownHostsFile $HOME/.ssh/known_hosts
PasswordAuthentication no
EOF
chmod 600 "$HOME/.ssh/config"
export GIT_SSH_COMMAND="ssh -F $HOME/.ssh/config"
log "SSH 認証を構成しました"
;;
https-token)
if [ ! -r /git-identity/token ]; then err "HTTPS トークンがマウントされていません"; exit 1; fi
_u="${GIT_HTTPS_USERNAME:-x-access-token}"
_t="$(cat /git-identity/token)"
# トークンをプロセス環境にもファイルにも平文で残さず、credential helper で都度供給
cat > "$HOME/.git-credentials" <<EOF
https://$(printf '%s' "$_u" | sed 's/@/%40/g'):$(printf '%s' "$_t" | sed 's/@/%40/g')@$(printf '%s\n' "$REPO_URL" | sed -E 's#^https://([^/]+)/.*#\1#')
EOF
chmod 600 "$HOME/.git-credentials"
git config --global credential.helper "store --file=$HOME/.git-credentials"
unset _t
log "HTTPS トークン認証を構成しました"
;;
*)
log "git 認証なし(公開リポジトリのみ clone 可能)"
;;
esac
# --------------------------------------------------------------------------
# 2. clone
# --------------------------------------------------------------------------
REPO_DIR="$WORKSPACE_DIR/$(basename "${REPO_URL%.git}")"
if [ -d "$REPO_DIR/.git" ]; then
log "既存チェックアウトを再利用: $REPO_DIR"
else
log "clone 中: $REPO_URL ($REPO_REF)"
if ! git clone --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR"; then
err "clone に失敗しました"; exit 1
fi
fi
cd "$REPO_DIR"
git checkout "$REPO_REF" 2>/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" <<EOF
#!/bin/sh
cd "$REPO_DIR" || cd "$WORKSPACE_DIR"
export PATH="$PATH"
echo "── Claude Code セッション $SESSION_ID ──────────────────────────"
echo "リポジトリ : $REPO_URL ($REPO_REF)"
echo "検証コマンド: ${VERIFY_COMMAND:-(未設定)} 補助: claude-verify / claude-push"
echo "─────────────────────────────────────────────────────────────"
if [ "${AUTOSTART_CLAUDE}" = "true" ] && command -v claude >/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"
+57
View File
@@ -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=<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 }}
+112
View File
@@ -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 -}}
+12
View File
@@ -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 }}
+10
View File
@@ -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 }}
+10
View File
@@ -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 }}
+71
View File
@@ -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 }}
+255
View File
@@ -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 }}
+40
View File
@@ -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 }}
+19
View File
@@ -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 }}
+19
View File
@@ -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 }}
+51
View File
@@ -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 }}
+75
View File
@@ -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 }}
+15
View File
@@ -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 }}
+40
View File
@@ -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 }}
+22
View File
@@ -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 }}
+18
View File
@@ -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 }}
+32
View File
@@ -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 }}
+30
View File
@@ -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 }}
+18
View File
@@ -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
+22
View File
@@ -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
+269
View File
@@ -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"] }
}
}
}
}
+338
View File
@@ -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 <host>` の出力(必須/ホスト鍵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