diff --git a/CHANGELOG-2.1.263-b.md b/CHANGELOG-2.1.263-b.md new file mode 100644 index 0000000..faa55d5 --- /dev/null +++ b/CHANGELOG-2.1.263-b.md @@ -0,0 +1,46 @@ +# CHANGELOG 2.1.263-b — 手動リリース + +v2.1.263-a の上に適用する手動リリース。 + +## 背景 + +`claude setup-token` / サブスクリプションのアクセストークンは定期的に失効・ローテーション +される。単発トークン(`authMode: oauth`)注入では、失効のたびに `helm upgrade` が必要だった。 + +## 追加 + +- **`anthropic.authMode: credentials`(新既定)** + - `~/.claude/.credentials.json` 全体(`accessToken` + `refreshToken` + `expiresAt`)を Secret に + 格納し、セッション Pod にファイルとしてマウント → 起動時に `$HOME/.claude/.credentials.json` + へコピー(書き込み可能)。セッション内 `claude` が `refreshToken` で自己更新する。 + - `--set-file anthropic.credentialsJson=$HOME/.claude/.credentials.json` で注入。 + - `anthropic.existingSecretKeyCredentials`(既定 `.credentials.json`)で既存 Secret のキー名を指定可。 +- **refresher CronJob(`anthropic.refresher`、`credentials` モード時・既定有効)** + - `files/token-refresher.py`(Python 標準ライブラリのみ)。Secret 内の `refreshToken` で + OAuth トークンエンドポイントを叩き、新しい `accessToken` / `refreshToken` / `expiresAt` を + Secret に書き戻す。既定 30 分ごと、残り寿命 1h 未満で更新。 + - 専用 ServiceAccount + Role(**セッション NS の認証情報 Secret 1 個への `get` / `patch` のみ**、 + `resourceNames` で限定)+ RoleBinding。 + - `tokenUrl` / `clientId` は Claude Code CLI の公開値(非公式)。仕様変更時は values で上書き。 + 壊れてもセッション内 `claude` の自己更新は独立して機能する。`enabled: false` で無効化。 +- Secret に `helm.sh/resource-policy: keep`(`credentials` モード時)。`helm upgrade` で + `--set-file` を付けない限り refresher が書いた値を Helm が上書きしない。 + +## 変更 + +- `anthropic.authMode` の既定を `apiKey` → **`credentials`**。既存の `apiKey` / `oauth` 運用は + values で明示指定すれば従来どおり。 +- `templates/secret-anthropic.yaml` / `deployment.yaml` / `files/manager.py`(`build_pod` の + 認証情報注入分岐)/ `files/session-entrypoint.sh`(seed 処理)/ `values.schema.json` / + `templates/NOTES.txt` / `examples/loadbalancer-oauth.values.yaml` を更新。 + +## 修正 + +- `values.schema.json` の `$schema` に `https://json-schema.org/draft-07/schema#` を指定して + いたため、一部の Helm バージョンで + `no schema with key or ref "https://json-schema.org/draft-07/schema#"` エラーが出ていた。 + `$schema` 行を削除(他チャートと同じ・Helm は draft-07 で検証)。 + +## バージョン + +`version` = `2.1.263-b` / `appVersion` = `"2.1.263"`。 diff --git a/CLAUDE.md b/CLAUDE.md index d68ccfc..8b109f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,33 @@ Git リポジトリ(GitHub / Gitea など互換 Git 全般)をチェック ## 改修履歴 +### 認証情報の credentials モード + トークン自動リフレッシュ(2026-09-08, v2.1.263-b) + +**背景**: サブスクリプションのアクセストークンは定期的に失効・ローテーションする。単発 +トークン注入では失効のたびに `helm upgrade` が必要だった。 + +**症状**: あわせて、一部の Helm バージョンで `values.schema.json` の +`$schema: https://json-schema.org/draft-07/schema#` が +`no schema with key or ref ...` エラーになる不具合。 + +**修正内容**: +- `anthropic.authMode: credentials`(新既定): `~/.claude/.credentials.json` 全体を Secret に + 格納 →(a)セッション Pod にファイルとしてマウントし `$HOME/.claude` へコピー、セッション内 + `claude` が `refreshToken` で自己更新。(b)`anthropic.refresher` CronJob + (`files/token-refresher.py`、専用 SA + `resourceNames` 限定 Role で該当 Secret の get/patch のみ) + がクラスタ内で `accessToken` を定期更新し Secret を書き戻す。`tokenUrl` / `clientId` は + Claude Code CLI の非公式値で values 上書き可。 +- `manager.py` の `build_pod`: credentials モードは `envFrom` を使わずファイルマウント + + `SEED_CREDENTIALS_FILE` env。apiKey/oauth は従来どおり `envFrom`。 +- Secret に `helm.sh/resource-policy: keep`(credentials 時)。 +- `values.schema.json` の `$schema` 行を削除(他チャートと同じ。Helm は draft-07 で検証)。 + +**対象ファイル**: `values.yaml`, `values.schema.json`, `templates/secret-anthropic.yaml`, +`templates/deployment.yaml`, `templates/configmap-manager.yaml`, `templates/token-refresher.yaml`(新規), +`templates/NOTES.txt`, `files/manager.py`, `files/session-entrypoint.sh`, +`files/token-refresher.py`(新規), `examples/loadbalancer-oauth.values.yaml`, `README.md`, +`Chart.yaml`, `CHANGELOG-2.1.263-b.md` + ### Service の LoadBalancer 対応 + サブスクリプション認証手順の明文化(2026-09-08, v2.1.263-a) **背景**: Ingress を張らずに、ローカルネットワークの LB(kube-vip 等)から払い出した IP で diff --git a/Chart.yaml b/Chart.yaml index 6d00808..c03c5eb 100644 --- a/Chart.yaml +++ b/Chart.yaml @@ -6,7 +6,7 @@ description: >- 使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・ 検証・プッシュする。一定時間アイドルなセッションは自動破棄される。 type: application -version: 2.1.263-a +version: 2.1.263-b appVersion: "2.1.263" keywords: - claude-code diff --git a/README.md b/README.md index 11f694c..84ec68a 100644 --- a/README.md +++ b/README.md @@ -119,41 +119,63 @@ manager / gateway / session-entrypoint の実体は `files/` にあり、`.Files **サブスクリプション認証(Pro / Max プラン)** で動かせます。セッション内の `claude` の 挙動・機能はローカル実行と同一です(同じ CLI・同じログイン方式)。 -1. 手元の PC(Claude にログイン済み)で長期トークンを発行: +`anthropic.authMode` は 3 つ: - ```bash - claude setup-token - ``` +| モード | 注入するもの | トークン更新 | 課金 | +|---|---|---|---| +| **`credentials`(既定・推奨)** | `~/.claude/.credentials.json` 全体(`refreshToken` 込み) | セッション内 `claude` が自己更新 **+** クラスタ内 refresher CronJob が Secret を自動更新 | サブスクリプション枠(API 課金なし) | +| `oauth` | `CLAUDE_CODE_OAUTH_TOKEN`(`claude setup-token` の単発トークン) | 自動更新なし(`refreshToken` を含まない)。失効したら手動再投入 | サブスクリプション枠 | +| `apiKey` | `ANTHROPIC_API_KEY`(`sk-ant-...`) | — | API 従量課金 | - ブラウザで承認すると `CLAUDE_CODE_OAUTH_TOKEN` として使える長期トークンが表示されます。 +### `credentials` モードの使い方(推奨) -2. それをチャートに渡す(`authMode: oauth`): +1. **claude にログイン済みの手元 PC** の認証情報ファイルをそのまま渡します + (このファイルには `accessToken` と、それを更新するための `refreshToken` が入っています): ```bash + # 場所: Linux/macOS = ~/.claude/.credentials.json + # Windows = %USERPROFILE%\.claude\.credentials.json + # (macOS でキーチェーン保存の場合は Keychain の "Claude Code" 項目を書き出す) helm upgrade --install claude-code cafepieters/claude-code -n claude-code --create-namespace \ -f examples/loadbalancer-oauth.values.yaml \ - --set anthropic.oauthToken="<発行されたトークン>" + --set-file anthropic.credentialsJson="$HOME/.claude/.credentials.json" ``` - Secret にしておく場合: +2. これだけで **トークンが定期的に変わっても再デプロイは不要** です。仕組み: + + - セッション Pod は生成のたびに Secret を読むため、常に最新の認証情報で起動します。 + - **セッション内の `claude`** は、`accessToken` が失効していれば `refreshToken` で + 自動更新します(そのセッションの間だけ有効。`$HOME/.claude/.credentials.json` に書き戻す)。 + - **refresher CronJob**(`anthropic.refresher`、既定 30 分ごと)が、Secret 内の + `refreshToken` を使ってクラスタ内で `accessToken` を更新し、**Secret 自体を書き戻します**。 + これにより手元 PC がオフラインでも、新規セッションは常に有効なトークンで起動できます。 + +3. 既存 Secret を使う場合: ```bash - kubectl -n claude-code-sessions create secret generic claude-oauth \ - --from-literal=CLAUDE_CODE_OAUTH_TOKEN="<トークン>" - # values: anthropic.existingSecret=claude-oauth + kubectl -n claude-code-sessions create secret generic claude-creds \ + --from-file=.credentials.json="$HOME/.claude/.credentials.json" + # values: anthropic.existingSecret=claude-creds + # (refresher に patch させるため RBAC はこの Secret 名に限定して付与される) ``` -| 方式 | values | 認証情報 | 課金 | -|---|---|---|---| -| **サブスクリプション(推奨)** | `anthropic.authMode: oauth` | `anthropic.oauthToken`(`claude setup-token`) | Claude プランの利用枠を消費(API 課金なし) | -| API キー | `anthropic.authMode: apiKey` | `anthropic.apiKey`(`sk-ant-...`) | API 従量課金 | +### 注意 / 制約 -- トークンは **セッション NS の Secret** に保存され、セッション Pod へ環境変数 - `CLAUDE_CODE_OAUTH_TOKEN` として注入されます(`values.yaml` に平文で書かない)。 -- 利用枠はローカルの Claude Code と共有です。複数セッションを同時に走らせると - プランのレート上限に早く達する点に注意してください。 -- トークンには有効期限があります。失効したら `claude setup-token` で再発行し - `helm upgrade --set anthropic.oauthToken=...`(または Secret 更新 + Pod 再起動)します。 +- 利用枠はローカルの Claude Code と共有です。同時セッションを増やすとプランの + レート上限に早く達します。 +- `session.maxLifetimeSeconds`(既定 12h)が `accessToken` の寿命より長いと、長時間 + セッションが自分でトークンを更新し、`refreshToken` のローテーションで refresher 側の + 値が一時的に古くなることがあります。refresher は短い周期(既定 30 分)で追従します。 +- **refresher は Claude Code CLI の OAuth クライアント値(`anthropic.refresher.tokenUrl` / + `clientId`)に依存します。これらは非公式で、仕様変更により動かなくなる可能性があります。** + その場合でもセッション内 `claude` の自己更新は独立して機能します(新規セッションの + 起動時トークンが古い場合のみ影響)。不要なら `anthropic.refresher.enabled: false`。 +- refresher の権限は **セッション NS の認証情報 Secret 1 個への `get` / `patch` のみ** + (`resourceNames` で限定した Role)。 +- `refreshToken` 自体が失効・失効させられた場合は、手元 PC で `claude` に再ログインして + `--set-file anthropic.credentialsJson=...` で再投入します。 +- Secret の中身は refresher が書き換えます。`helm upgrade` で `--set-file` を付けない限り + Helm は Secret の値を上書きしません(`helm.sh/resource-policy: keep` + 3-way merge)。 ## 管理画面へのアクセス(LoadBalancer / Ingress) @@ -226,7 +248,8 @@ ingress: - LoadBalancer 実装(kube-vip / MetalLB / Cilium LB-IPAM など) … 既定 - Ingress コントローラー … `ingress.enabled: true` の場合 - どちらも無い場合は `service.type: ClusterIP` + `kubectl port-forward` で利用可 -- Claude の認証情報 … `claude setup-token` の OAuth トークン(推奨)または `ANTHROPIC_API_KEY` +- Claude の認証情報 … `~/.claude/.credentials.json`(既定 `credentials` モード・推奨) + または `claude setup-token` の OAuth トークン、`ANTHROPIC_API_KEY` ### Helm リポジトリから @@ -242,16 +265,17 @@ helm upgrade --install claude-code cafepieters/claude-code \ ### ローカル LB の IP + サブスクリプション認証(API 不要・推奨構成) ```bash -claude setup-token # 手元の PC で長期 OAuth トークンを発行 - +# 手元の PC は claude にログイン済みであること(~/.claude/.credentials.json が存在) helm upgrade --install claude-code cafepieters/claude-code \ -n claude-code --create-namespace \ -f examples/loadbalancer-oauth.values.yaml \ - --set anthropic.oauthToken="<発行されたトークン>" \ + --set-file anthropic.credentialsJson="$HOME/.claude/.credentials.json" \ --set-file git.identities.gitea-ssh.sshPrivateKey=P:/Claude/.ssh/id_claude \ --set-file git.identities.gitea-ssh.knownHosts=./known_hosts.gitea ``` +以降トークンが変わっても再デプロイは不要です(refresher CronJob が Secret を追従更新)。 + `examples/loadbalancer-oauth.values.yaml` は Ingress 無効・`service.type: LoadBalancer` + kube-vip の VIP 指定 + `loadBalancerSourceRanges` によるローカルセグメント限定の構成です。 @@ -352,14 +376,23 @@ ssh-keyscan 192.168.9.65 > known_hosts.gitea ### anthropic — Claude Code 認証情報 +詳細は [Claude 認証(API 課金なし / サブスクリプション)](#claude-認証api-課金なし--サブスクリプション) を参照。 + | キー | 既定 | 説明 | |---|---|---| -| `anthropic.authMode` | `apiKey` | `apiKey`(`ANTHROPIC_API_KEY`)/ `oauth`(`CLAUDE_CODE_OAUTH_TOKEN`) | +| `anthropic.authMode` | `credentials` | `credentials`(`~/.claude/.credentials.json`・推奨)/ `oauth`(`CLAUDE_CODE_OAUTH_TOKEN`)/ `apiKey`(`ANTHROPIC_API_KEY`) | +| `anthropic.credentialsJson` | `""` | `--set-file anthropic.credentialsJson=$HOME/.claude/.credentials.json` で注入 | | `anthropic.apiKey` / `anthropic.oauthToken` | `""` | `--set` / `--set-file` で注入 | -| `anthropic.existingSecret` | `""` | 既存 Secret を使う(キー名は `existingSecretKeyApiKey` / `existingSecretKeyOauthToken`) | +| `anthropic.existingSecret` | `""` | 既存 Secret を使う(キー名は `existingSecretKeyCredentials`(既定 `.credentials.json`)/ `...ApiKey` / `...OauthToken`) | +| `anthropic.refresher.enabled` | `true` | `credentials` モードで、Secret 内 `refreshToken` を使いトークンを定期更新する CronJob を作成 | +| `anthropic.refresher.schedule` | `*/30 * * * *` | refresher CronJob のスケジュール | +| `anthropic.refresher.refreshIfExpiresInSeconds` | `3600` | `accessToken` の残り寿命がこの秒数未満なら更新 | +| `anthropic.refresher.tokenUrl` / `clientId` | Claude Code CLI の公開値 | OAuth トークンエンドポイントとクライアント ID(**非公式・仕様変更で要更新**) | | `anthropic.extraEnv` | `{}` | 全セッションへ渡す追加 env(例 `ANTHROPIC_BASE_URL`、Bedrock/Vertex 切替など) | -Secret は **セッション NS** に作成され、セッション Pod へ `envFrom` で注入されます。 +Secret は **セッション NS** に作成されます。`credentials` モードはファイルとしてマウントし +セッション起動時に `$HOME/.claude/.credentials.json` へコピー(書き込み可能)、 +`apiKey` / `oauth` モードは `envFrom` で環境変数として注入します。 ### git — 接続先と認証アイデンティティ @@ -492,6 +525,7 @@ docker buildx build --platform linux/amd64,linux/arm64 \ | バージョン | Claude Code | 変更内容 | |---|---|---| +| 2.1.263-b | 2.1.263 | `anthropic.authMode: credentials`(既定)を追加。`~/.claude/.credentials.json` 全体を注入し、セッション内 `claude` の自己更新に加え、クラスタ内 refresher CronJob(`refreshToken` で Secret を定期更新)でトークン失効に自動追従。→ トークンが変わっても再デプロイ不要。refresher の RBAC は認証情報 Secret 1 個の get/patch に限定 | | 2.1.263-a | 2.1.263 | Service を `LoadBalancer` 対応(kube-vip 等でローカル LB の IP から管理画面へアクセス、`loadBalancerSourceRanges` で接続元制限、既定を `LoadBalancer` に変更)。Ingress は従来どおり併用可。API を使わないサブスクリプション認証(`claude setup-token` → `authMode: oauth`)の手順を README に追記 | | 2.1.263 | 2.1.263 | 自動更新 | | 2.1.259 | 2.1.259 | 初版。gateway + manager + 使い捨て session Pod + reaper、GitHub/Gitea 対応、アイドル自動破棄、NetworkPolicy / RBAC 最小化 / restricted PodSecurity | diff --git a/examples/loadbalancer-oauth.values.yaml b/examples/loadbalancer-oauth.values.yaml index 0459f38..d63beea 100644 --- a/examples/loadbalancer-oauth.values.yaml +++ b/examples/loadbalancer-oauth.values.yaml @@ -3,20 +3,23 @@ # # 前提: # - クラスタに LB 実装(kube-vip / MetalLB / Cilium LB-IPAM 等)がある -# - ローカルで `claude setup-token` を実行して長期 OAuth トークンを発行済み +# - 手元の PC が claude にログイン済み(~/.claude/.credentials.json が存在) # # 実行例: # helm upgrade --install claude-code cafepieters/claude-code \ # -n claude-code --create-namespace \ # -f examples/loadbalancer-oauth.values.yaml \ -# --set anthropic.oauthToken="$(cat ~/.claude/oauth-token.txt)" \ +# --set-file anthropic.credentialsJson="$HOME/.claude/.credentials.json" \ # --set-file git.identities.gitea-ssh.sshPrivateKey=P:/Claude/.ssh/id_claude \ # --set-file git.identities.gitea-ssh.knownHosts=./known_hosts.gitea # ---- Claude 認証: API を使わずサブスクリプション(Pro / Max)で動かす ---- anthropic: - authMode: oauth # ANTHROPIC_API_KEY ではなく CLAUDE_CODE_OAUTH_TOKEN を使う - # oauthToken は --set / --set-file / existingSecret で注入する(平文で書かない) + authMode: credentials # ~/.claude/.credentials.json 全体を注入(refreshToken 込み) + # credentialsJson は --set-file で注入する(平文で書かない) + refresher: + enabled: true # クラスタ内で accessToken を定期更新し Secret を書き戻す + schedule: "*/30 * * * *" # ---- 管理画面はローカル LB の IP でアクセス(Ingress なし)---- ingress: diff --git a/files/manager.py b/files/manager.py index 773b18b..e65260f 100644 --- a/files/manager.py +++ b/files/manager.py @@ -54,6 +54,8 @@ 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") +ANTHROPIC_AUTH_MODE = env("ANTHROPIC_AUTH_MODE", "apiKey") +ANTHROPIC_CREDENTIALS_KEY = env("ANTHROPIC_CREDENTIALS_KEY", ".credentials.json") 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" @@ -249,13 +251,33 @@ def build_pod(sid, repo, ref, identity_name, owner): mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True}) env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")}) + # ---- Claude 認証情報の渡し方 ---- + # credentials モード: Secret をファイルとしてマウントし、エントリポイントが + # $HOME/.claude/.credentials.json へコピーする(書き込み可能にして claude 自身の + # リフレッシュを許す)。env var 名に "." を含められないため envFrom は使わない。 + # apiKey / oauth モード: 有効な env var 名なので envFrom でそのまま注入する。 + env_from = [] + if ANTHROPIC_AUTH_MODE == "credentials": + volumes.append({ + "name": "anthropic-creds", + "secret": { + "secretName": ANTHROPIC_SECRET, + "defaultMode": 0o400, + "items": [{"key": ANTHROPIC_CREDENTIALS_KEY, "path": "credentials.json"}], + }, + }) + mounts.append({"name": "anthropic-creds", "mountPath": "/anthropic", "readOnly": True}) + env_list.append({"name": "SEED_CREDENTIALS_FILE", "value": "/anthropic/credentials.json"}) + else: + env_from = [{"secretRef": {"name": ANTHROPIC_SECRET}}] + 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}}], + "envFrom": env_from, "ports": [{"name": "ttyd", "containerPort": TTYD_PORT}], "volumeMounts": mounts, "resources": SESSION_RESOURCES, diff --git a/files/session-entrypoint.sh b/files/session-entrypoint.sh index bf5e789..07e5299 100644 --- a/files/session-entrypoint.sh +++ b/files/session-entrypoint.sh @@ -114,6 +114,25 @@ if ! command -v claude >/dev/null 2>&1; then fi log "claude: $(claude --version 2>/dev/null || echo 'unknown')" +# -------------------------------------------------------------------------- +# 3b. Claude 認証情報(credentials モード) +# -------------------------------------------------------------------------- +# Secret からマウントした .credentials.json を書き込み可能な $HOME/.claude へコピーする。 +# claude はアクセストークン失効時に refreshToken でこのファイルを自己更新する +# (セッション中のみ有効。クラスタ全体の更新は refresher CronJob が担当)。 +if [ -n "${SEED_CREDENTIALS_FILE:-}" ] && [ -r "$SEED_CREDENTIALS_FILE" ]; then + mkdir -p "$HOME/.claude" + chmod 700 "$HOME/.claude" + install -m 600 "$SEED_CREDENTIALS_FILE" "$HOME/.claude/.credentials.json" + if command -v jq >/dev/null 2>&1; then + _exp="$(jq -r '.claudeAiOauth.expiresAt // empty' "$HOME/.claude/.credentials.json" 2>/dev/null)" + [ -n "$_exp" ] && log "Claude 認証情報を配置(accessToken 期限: $(date -u -d "@$(( _exp / 1000 ))" 2>/dev/null || echo "$_exp"))" + else + log "Claude 認証情報を $HOME/.claude/.credentials.json に配置しました" + fi + unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN +fi + # 追加 apk(root 権限がある事前ビルドイメージのみ有効。非 root では警告のみ) if [ -n "${EXTRA_APK_PACKAGES:-}" ]; then if [ "$(id -u)" = "0" ]; then diff --git a/files/token-refresher.py b/files/token-refresher.py new file mode 100644 index 0000000..b4714f1 --- /dev/null +++ b/files/token-refresher.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +claude-code token-refresher — credentials モードのアクセストークン更新(CronJob) + +Secret 内の ~/.claude/.credentials.json(claudeAiOauth.refreshToken)を使って +新しい accessToken を取得し、Secret を書き戻す。これにより、認証情報を注入した +マシンがオフラインでも、新規セッションは常に有効なトークンで起動できる。 + +依存: Python 標準ライブラリのみ。 +権限: セッション NS の対象 Secret 1 個に対する get / patch のみ(RBAC で限定)。 + +⚠ tokenUrl / clientId は Claude Code CLI の OAuth クライアント値(非公式)。 + 仕様変更で 4xx が返るようになったらここを更新する。その場合でも、 + セッション内 claude の自己リフレッシュは独立して機能する。 +""" + +import base64 +import json +import os +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +NS = os.environ["SESSIONS_NAMESPACE"] +SECRET = os.environ["ANTHROPIC_SECRET"] +KEY = os.environ.get("CREDENTIALS_KEY", ".credentials.json") +TOKEN_URL = os.environ.get("TOKEN_URL", "https://console.anthropic.com/v1/oauth/token") +CLIENT_ID = os.environ.get("CLIENT_ID", "9d1c250a-e61b-44d9-88ed-5944d1962f5e") +MARGIN = int(os.environ.get("REFRESH_IF_EXPIRES_IN", "3600")) + +_SA = "/var/run/secrets/kubernetes.io/serviceaccount" +with open(_SA + "/token", encoding="utf-8") as fh: + _K8S_TOKEN = fh.read().strip() +_CTX = ssl.create_default_context(cafile=_SA + "/ca.crt") +_API = "https://kubernetes.default.svc" + + +def log(msg): + print(time.strftime("%Y-%m-%dT%H:%M:%S"), msg, flush=True) + + +def k8s(method, path, body=None, ctype="application/json"): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(_API + path, data=data, method=method) + req.add_header("Authorization", "Bearer " + _K8S_TOKEN) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", ctype) + with urllib.request.urlopen(req, context=_CTX, timeout=30) as resp: + raw = resp.read() + return json.loads(raw) if raw else {} + + +def secret_path(): + return "/api/v1/namespaces/%s/secrets/%s" % (urllib.parse.quote(NS), urllib.parse.quote(SECRET)) + + +def oauth_refresh(refresh_token): + payload = json.dumps({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": CLIENT_ID, + }).encode() + req = urllib.request.Request(TOKEN_URL, data=payload, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + req.add_header("User-Agent", "claude-code-helm-token-refresher") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", "replace")[:400] + raise SystemExit("token endpoint HTTP %s: %s" % (exc.code, body)) + + +def main(): + sec = k8s("GET", secret_path()) + b64 = (sec.get("data") or {}).get(KEY) + if not b64: + raise SystemExit("secret %s/%s にキー %r がありません" % (NS, SECRET, KEY)) + try: + creds = json.loads(base64.b64decode(b64).decode("utf-8")) + except Exception as exc: # noqa: BLE001 + raise SystemExit("credentials JSON をパースできません: %s" % exc) + + oauth = creds.get("claudeAiOauth") + if not isinstance(oauth, dict) or not oauth.get("refreshToken"): + raise SystemExit("claudeAiOauth.refreshToken がありません(credentials モードの Secret ではない?)") + + now_ms = int(time.time() * 1000) + expires_at = int(oauth.get("expiresAt") or 0) + if expires_at and now_ms < expires_at - MARGIN * 1000: + remain = (expires_at - now_ms) // 1000 + log("まだ有効(残り %ds、しきい値 %ds)。更新不要。" % (remain, MARGIN)) + return + + log("トークンを更新します(expiresAt=%s, now=%s)" % (expires_at or "?", now_ms)) + resp = oauth_refresh(oauth["refreshToken"]) + access = resp.get("access_token") + if not access: + raise SystemExit("応答に access_token がありません: %s" % json.dumps(resp)[:300]) + + oauth["accessToken"] = access + if resp.get("refresh_token"): + oauth["refreshToken"] = resp["refresh_token"] + if resp.get("expires_in"): + oauth["expiresAt"] = now_ms + int(resp["expires_in"]) * 1000 + creds["claudeAiOauth"] = oauth + + new_b64 = base64.b64encode(json.dumps(creds).encode("utf-8")).decode("ascii") + patch = {"data": {KEY: new_b64}} + k8s("PATCH", secret_path(), patch, ctype="application/merge-patch+json") + log("Secret %s/%s を更新しました(新 expiresAt=%s)" % (NS, SECRET, oauth.get("expiresAt"))) + + +if __name__ == "__main__": + try: + main() + except SystemExit as exc: + if exc.code not in (0, None): + log("ERROR: %s" % exc.code) + raise diff --git a/templates/NOTES.txt b/templates/NOTES.txt index 1b3ebe2..1403598 100644 --- a/templates/NOTES.txt +++ b/templates/NOTES.txt @@ -42,10 +42,16 @@ Basic 認証のユーザーでログインしてください。 ⚠ auth.mode=none — 認証なしで公開されています。信頼済みネットワーク限定で使用してください。 {{- end }} -{{- if and (not .Values.anthropic.existingSecret) (not .Values.anthropic.apiKey) (not .Values.anthropic.oauthToken) }} +認証方式 : anthropic.authMode = {{ .Values.anthropic.authMode }} +{{- if and (eq .Values.anthropic.authMode "credentials") .Values.anthropic.refresher.enabled }} +トークン更新: refresher CronJob({{ .Values.anthropic.refresher.schedule }})が Secret を自動更新 +{{- end }} +{{- if and (not .Values.anthropic.existingSecret) (not .Values.anthropic.apiKey) (not .Values.anthropic.oauthToken) (not .Values.anthropic.credentialsJson) }} -⚠ Claude Code の認証情報が未設定です(anthropic.apiKey / oauthToken / existingSecret のいずれも空)。 - セッション内の `claude` は認証に失敗します。`--set anthropic.apiKey=...` で再適用してください。 +⚠ Claude Code の認証情報が未設定です(anthropic.apiKey / oauthToken / credentialsJson / existingSecret のいずれも空)。 + セッション内の `claude` は認証に失敗します。credentials モードなら: + --set-file anthropic.credentialsJson=$HOME/.claude/.credentials.json + で再適用してください(helm upgrade)。 {{- end }} {{- if not .Values.git.identities }} diff --git a/templates/configmap-manager.yaml b/templates/configmap-manager.yaml index 6c11e28..3188104 100644 --- a/templates/configmap-manager.yaml +++ b/templates/configmap-manager.yaml @@ -8,3 +8,5 @@ metadata: data: manager.py: | {{ .Files.Get "files/manager.py" | indent 4 }} + token-refresher.py: | +{{ .Files.Get "files/token-refresher.py" | indent 4 }} diff --git a/templates/deployment.yaml b/templates/deployment.yaml index f0ce066..4ca2cf6 100644 --- a/templates/deployment.yaml +++ b/templates/deployment.yaml @@ -131,6 +131,10 @@ spec: value: {{ include "claude-code.fullname" . }}-session - name: ANTHROPIC_SECRET value: {{ include "claude-code.anthropicSecretName" . | quote }} + - name: ANTHROPIC_AUTH_MODE + value: {{ .Values.anthropic.authMode | quote }} + - name: ANTHROPIC_CREDENTIALS_KEY + value: {{ .Values.anthropic.existingSecretKeyCredentials | default ".credentials.json" | quote }} - name: GIT_IDENTITIES_JSON value: {{ $idents | toJson | quote }} - name: GIT_ALLOWED_HOSTS diff --git a/templates/secret-anthropic.yaml b/templates/secret-anthropic.yaml index ebc523a..79bf75e 100644 --- a/templates/secret-anthropic.yaml +++ b/templates/secret-anthropic.yaml @@ -1,6 +1,7 @@ {{- if not .Values.anthropic.existingSecret }} -# Claude Code 認証情報。セッション NS に作成し、セッション Pod へ env として注入する。 -# 値が空のままインストールするとセッション内の claude が認証に失敗する(NOTES 参照)。 +# Claude Code 認証情報。セッション NS に作成し、セッション Pod へ注入する。 +# credentials モードでは refresher CronJob がこの Secret を書き換えるため、 +# helm の管理から外れて中身が更新されうる(helm upgrade で上書きしないよう注意)。 apiVersion: v1 kind: Secret metadata: @@ -8,9 +9,15 @@ metadata: namespace: {{ include "claude-code.sessionsNamespace" . }} labels: {{- include "claude-code.labels" . | nindent 4 }} + annotations: + {{- if eq .Values.anthropic.authMode "credentials" }} + "helm.sh/resource-policy": keep + {{- end }} type: Opaque stringData: - {{- if eq .Values.anthropic.authMode "oauth" }} + {{- if eq .Values.anthropic.authMode "credentials" }} + ".credentials.json": {{ .Values.anthropic.credentialsJson | default "{}" | quote }} + {{- else if eq .Values.anthropic.authMode "oauth" }} CLAUDE_CODE_OAUTH_TOKEN: {{ .Values.anthropic.oauthToken | quote }} {{- else }} ANTHROPIC_API_KEY: {{ .Values.anthropic.apiKey | quote }} diff --git a/templates/token-refresher.yaml b/templates/token-refresher.yaml new file mode 100644 index 0000000..bcb80e0 --- /dev/null +++ b/templates/token-refresher.yaml @@ -0,0 +1,115 @@ +{{- if and (eq .Values.anthropic.authMode "credentials") .Values.anthropic.refresher.enabled }} +{{- $sa := printf "%s-refresher" (include "claude-code.fullname" .) -}} +{{- $sessNs := include "claude-code.sessionsNamespace" . -}} +{{- $secret := include "claude-code.anthropicSecretName" . -}} +{{- $r := .Values.anthropic.refresher -}} +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ $sa }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + app.kubernetes.io/component: token-refresher +automountServiceAccountToken: true +--- +# セッション NS の Claude 認証情報 Secret 1 個に対する get / patch のみ。 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "claude-code.fullname" . }}-refresher + namespace: {{ $sessNs }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: [{{ $secret | quote }}] + verbs: ["get", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "claude-code.fullname" . }}-refresher + namespace: {{ $sessNs }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "claude-code.fullname" . }}-refresher +subjects: + - kind: ServiceAccount + name: {{ $sa }} + namespace: {{ .Release.Namespace }} +--- +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ include "claude-code.fullname" . }}-token-refresher + namespace: {{ .Release.Namespace }} + labels: + {{- include "claude-code.labels" . | nindent 4 }} + app.kubernetes.io/component: token-refresher +spec: + schedule: {{ $r.schedule | quote }} + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 1 + failedJobsHistoryLimit: 3 + startingDeadlineSeconds: 120 + jobTemplate: + spec: + activeDeadlineSeconds: 120 + backoffLimit: 2 + template: + metadata: + labels: + {{- include "claude-code.selectorLabels" . | nindent 12 }} + app.kubernetes.io/component: token-refresher + spec: + restartPolicy: Never + serviceAccountName: {{ $sa }} + automountServiceAccountToken: true + securityContext: + {{- include "claude-code.podSecurityContext" . | nindent 12 }} + containers: + - name: token-refresher + image: "{{ .Values.image.manager.registry }}/{{ .Values.image.manager.repository }}:{{ .Values.image.manager.tag }}" + imagePullPolicy: {{ .Values.image.manager.pullPolicy }} + command: ["python3", "-u", "/app/token-refresher.py"] + env: + - name: PYTHONDONTWRITEBYTECODE + value: "1" + - name: SESSIONS_NAMESPACE + value: {{ $sessNs | quote }} + - name: ANTHROPIC_SECRET + value: {{ $secret | quote }} + - name: CREDENTIALS_KEY + value: {{ .Values.anthropic.existingSecretKeyCredentials | default ".credentials.json" | quote }} + - name: TOKEN_URL + value: {{ $r.tokenUrl | quote }} + - name: CLIENT_ID + value: {{ $r.clientId | quote }} + - name: REFRESH_IF_EXPIRES_IN + value: {{ $r.refreshIfExpiresInSeconds | quote }} + securityContext: + {{- include "claude-code.containerSecurityContext" . | nindent 16 }} + resources: + {{- toYaml $r.resources | nindent 16 }} + volumeMounts: + - name: app + mountPath: /app + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: app + configMap: + name: {{ include "claude-code.fullname" . }}-manager + defaultMode: 0555 + - name: tmp + emptyDir: + medium: Memory + sizeLimit: 8Mi +{{- end }} diff --git a/values.schema.json b/values.schema.json index f22e637..741b740 100644 --- a/values.schema.json +++ b/values.schema.json @@ -1,5 +1,4 @@ { - "$schema": "https://json-schema.org/draft-07/schema#", "title": "claude-code Helm chart values", "type": "object", "additionalProperties": true, @@ -109,13 +108,27 @@ "additionalProperties": false, "required": ["authMode"], "properties": { - "authMode": { "type": "string", "enum": ["apiKey", "oauth"] }, + "authMode": { "type": "string", "enum": ["apiKey", "oauth", "credentials"] }, "apiKey": { "type": "string" }, "oauthToken": { "type": "string" }, + "credentialsJson": { "type": "string" }, "existingSecret": { "type": "string" }, "existingSecretKeyApiKey": { "type": "string" }, "existingSecretKeyOauthToken": { "type": "string" }, - "extraEnv": { "type": "object" } + "existingSecretKeyCredentials": { "type": "string" }, + "extraEnv": { "type": "object" }, + "refresher": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "schedule": { "type": "string" }, + "refreshIfExpiresInSeconds": { "type": "integer", "minimum": 60 }, + "tokenUrl": { "type": "string" }, + "clientId": { "type": "string" }, + "resources": { "type": "object" } + } + } } }, diff --git a/values.yaml b/values.yaml index 65e1a6a..675273c 100644 --- a/values.yaml +++ b/values.yaml @@ -153,18 +153,52 @@ auth: # anthropic: Claude Code の認証情報 # -------------------------------------------------------------------------- anthropic: - # apiKey : ANTHROPIC_API_KEY を使う - # oauth : CLAUDE_CODE_OAUTH_TOKEN を使う(`claude setup-token` で発行) - authMode: apiKey + # authMode: + # credentials : ~/.claude/.credentials.json 全体(refreshToken 込み)を注入(推奨)。 + # 各セッションはこのファイルを $HOME/.claude/.credentials.json に配置し、 + # claude が自身でアクセストークンをリフレッシュする。加えて下記 + # refresher CronJob がクラスタ内で Secret を最新に保つ。 + # → API 課金なし。トークンが定期的に変わっても再デプロイ不要。 + # oauth : CLAUDE_CODE_OAUTH_TOKEN を注入(`claude setup-token` の単発トークン。 + # refreshToken を含まないため自動更新されない)。 + # apiKey : ANTHROPIC_API_KEY を注入(API 従量課金)。 + authMode: credentials apiKey: "" oauthToken: "" + # credentials モード: claude ログイン済みマシンの ~/.claude/.credentials.json の中身。 + # 例: {"claudeAiOauth":{"accessToken":"...","refreshToken":"...","expiresAt":..., + # "scopes":["user:inference","user:profile"],"subscriptionType":"max"}} + # 注入は平文で書かず: --set-file anthropic.credentialsJson=$HOME/.claude/.credentials.json + credentialsJson: "" # 上記の代わりに既存 Secret を参照 existingSecret: "" existingSecretKeyApiKey: "ANTHROPIC_API_KEY" existingSecretKeyOauthToken: "CLAUDE_CODE_OAUTH_TOKEN" + existingSecretKeyCredentials: ".credentials.json" # 全セッションへ渡す追加環境変数(例: ANTHROPIC_BASE_URL / ANTHROPIC_MODEL) extraEnv: {} + # ---- refresher: credentials モードのトークン自動更新 CronJob ---- + # Secret 内の refreshToken を使って新しい accessToken を取得し、Secret を書き戻す。 + # これで自マシンがオフラインでも新規セッションは常に有効なトークンで起動できる。 + # ⚠ Claude Code CLI の OAuth クライアント値(tokenUrl / clientId)に依存する。 + # これらは非公式であり、仕様変更で動かなくなる可能性がある。その場合でも + # セッション内 claude の自己リフレッシュは独立して機能する。 + refresher: + enabled: true + schedule: "*/30 * * * *" # 30 分ごと + # 期限まで残りこの秒数を切っていたら更新する(十分な余裕を持たせる) + refreshIfExpiresInSeconds: 3600 + tokenUrl: "https://console.anthropic.com/v1/oauth/token" + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 200m + memory: 96Mi + # -------------------------------------------------------------------------- # git: 接続先ホストと認証アイデンティティ # --------------------------------------------------------------------------