9 Commits
Author SHA1 Message Date
claude 3dd802d44d chore: update Claude Code to 2.1.283 (nginx 1.31.6-alpine, python 3.14-alpine) 2026-09-27 18:04:38 +00:00
claude e1ef5a7310 chore: update Claude Code to 2.1.278 (nginx 1.31.6-alpine, python 3.14-alpine) 2026-09-20 18:00:21 +00:00
claude 351df17133 chore: update Claude Code to 2.1.270 (nginx 1.31.5-alpine, python 3.14-alpine) 2026-09-13 18:04:34 +00:00
claude c31dd5875c feat: git identity を Web UI から登録・削除できるようにする (v2.1.263-f)
Helm Chart Release / release-chart (push) Successful in 7s
Build Session Image / build-push (push) Successful in 2m5s
helm --set-file ではなく UI から SSH 秘密鍵 + known_hosts / HTTPS トークンを登録可能に。

- git.guiIdentities.enabled(既定 true)。UI に「認証情報」カード(一覧/追加/削除)
- GET/POST/DELETE /api/identities。登録分はセッション NS の Secret
  (ラベル identity-source=gui / identity-type)として保存。Helm 管理分と同一覧に統合
- manager.py: resolve_identity / list_identities / create_gui_identity / delete_gui_identity。
  build_pod と create_session は Helm 管理 + GUI 登録の両方を解決。
  /api/config の identities を {name,type,source} 配列に変更
- rbac.yaml: guiIdentities.enabled 時のみ manager Role に同 NS secrets の CRUD を付与
- 秘密鍵はリクエストボディで manager へ届くため README に経路 TLS 必須を明記
- helm lint / template 検証済み
2026-09-09 12:47:50 +09:00
claude 6514747b4e feat: リポジトリ URL バリデーションを緩和しエラー文言を具体化 (v2.1.263-e)
Helm Chart Release / release-chart (push) Failing after 9s
Build Session Image / build-push (push) Successful in 2m9s
claude@host:path のような SSH ユーザー違いの URL が「形式が不正」で弾かれ
原因が分かりにくかった。Gitea/GitHub の SSH ユーザーは常に git。

- REPO_RE を ssh://<user>@host/… / <user>@host:path / host:path も受理するよう緩和
  (// 始まりは拒否、ホスト allowlist は全形式に適用)
- URL 形式エラーで受理形式と「SSH ユーザーは常に git」を明記、
  ホスト不許可で許可済み一覧を併記
- UI のリポジトリ URL 入力欄にヒントを追加、README に URL 形式表を追加
2026-09-09 11:41:41 +09:00
claude 1b74e230ad feat: 失敗セッションの調査性を改善(終了Pod保持 + ログAPI + UI)(v2.1.263-d)
Helm Chart Release / release-chart (push) Successful in 7s
Build Session Image / build-push (push) Successful in 2m3s
セッション Pod がブート失敗で Failed になると reaper が即削除し kubectl logs で
死因を追えなかった。「開く」で 502 になるだけで原因不明だった。

- session.reaper.terminatedRetentionSeconds(既定 900): 終了済み Pod を
  finishedAt からこの秒数は回収しない。manager 回収ループと reaper CronJob 両方が尊重
- GET /api/sessions/<id>/log: セッション Pod のログを text で返す(pods/log get のみ、追加権限なし)
- UI: 「ログ」ボタン、状態の色分け、Failed 行に reason/exitCode/message 併記、
  Running 以外は「開く」非表示
- session-entrypoint.sh: URL スキームと identity 型の不一致を警告。
  git clone を --progress ... 2>&1 にして git/ssh の出力を Pod ログへ
- helm lint / template 検証済み
2026-09-08 21:14:56 +09:00
claude 6ec4378047 fix: gateway の nginx クラッシュループを修正(location 正規表現をクォート)(v2.1.263-c)
Helm Chart Release / release-chart (push) Successful in 7s
Build Session Image / build-push (push) Successful in 2m0s
nginx: [emerg] pcre2_compile() failed: missing closing parenthesis in
"^/s/(?<sid>[a-f0-9]" で gateway が CrashLoopBackOff になっていた。

location ~ の正規表現に含まれる {10} の { } を nginx 設定パーサーがブロック
区切りと解釈していた。nginx は { } ; を含む正規表現のクォートを必須とするため、
files/gateway.conf.template の該当 location をダブルクォートで囲んだ。
envsubst の置換は ${...} 6 変数のみで {10} に影響しないことを確認済み。
2026-09-08 18:12:57 +09:00
claude 528a48b667 feat: credentials 認証モードとトークン自動リフレッシュ CronJob を追加 (v2.1.263-b)
Helm Chart Release / release-chart (push) Successful in 9s
Build Session Image / build-push (push) Successful in 2m8s
トークンが定期的に失効・ローテーションしても再デプロイ不要にする。

- anthropic.authMode: credentials(新既定)
  ~/.claude/.credentials.json 全体(refreshToken 込み)を Secret に格納。
  セッション Pod にはファイルとしてマウントし $HOME/.claude へコピー(書き込み可)、
  セッション内 claude が refreshToken で自己更新
- anthropic.refresher CronJob(credentials 時・既定有効, files/token-refresher.py)
  Secret 内 refreshToken で OAuth トークンを更新し Secret を書き戻す。
  専用 SA + resourceNames 限定 Role で該当 Secret の get/patch のみ。
  tokenUrl/clientId は Claude Code CLI の非公式値(values 上書き可)
- manager.py build_pod: credentials は envFrom を使わずファイルマウント +
  SEED_CREDENTIALS_FILE。apiKey/oauth は従来どおり envFrom
- Secret に helm.sh/resource-policy: keep(credentials 時)で refresher の書込を保持
- fix: values.schema.json の $schema 行を削除(一部の Helm で
  "no schema with key or ref https://json-schema.org/draft-07/schema#" エラー)
- helm lint / template(credentials/apiKey/oauth)検証済み
2026-09-08 15:18:21 +09:00
claude cd7092be17 feat: Service を LoadBalancer 対応にしローカル LB の IP で管理画面へアクセス可能に (v2.1.263-a)
Helm Chart Release / release-chart (push) Successful in 7s
Build Session Image / build-push (push) Successful in 2m3s
- templates/service.yaml: service.type 既定を LoadBalancer に。annotations
  (kube-vip の VIP 指定等) / loadBalancerIP / loadBalancerClass /
  loadBalancerSourceRanges / externalTrafficPolicy / nodePort / labels を追加。
  Ingress 機能は変更なし(LoadBalancer と併用可)
- values.yaml / values.schema.json: service ブロックを拡張
- templates/NOTES.txt: LoadBalancer の EXTERNAL-IP 案内を追加
- examples/loadbalancer-oauth.values.yaml: Ingress 無効 + LB + source range 制限 +
  サブスクリプション認証 (authMode: oauth) の構成例を追加
- README: 「Claude 認証(API 課金なし / サブスクリプション)」「管理画面への
  アクセス(LoadBalancer / Ingress)」セクションを追加。claude setup-token →
  anthropic.authMode: oauth(CLAUDE_CODE_OAUTH_TOKEN 注入)でローカル実行と同一挙動
- helm lint / template 検証済み
2026-09-08 14:01:34 +09:00
25 changed files with 1454 additions and 71 deletions
+34
View File
@@ -0,0 +1,34 @@
# CHANGELOG 2.1.263-a — 手動リリース
v2.1.263(自動更新)の上に適用する手動リリース。
## 追加 / 変更
- **Service の LoadBalancer 対応(既定を `LoadBalancer` に変更)**
- `service.annotations` … LB 実装へのヒント。kube-vip の VIP 指定
(`kube-vip.io/loadbalancerIPs` / `kube-vip.io/vipHost`)、MetalLB のプール指定など。
- `service.loadBalancerIP` / `service.loadBalancerClass`
- `service.loadBalancerSourceRanges` … 接続元 CIDR を制限。Ingress を張らない構成での
第一の防御線。
- `service.externalTrafficPolicy` … `Local` で実クライアント IP を保持。
- `service.nodePort` … `type: NodePort` 時の固定ポート。
- `service.labels`
- Ingress 機能(`ingress.*`)は変更なし。LoadBalancer と併用できる。
- `templates/NOTES.txt` に LoadBalancer の EXTERNAL-IP 確認手順を追加。
- `examples/loadbalancer-oauth.values.yaml` を追加
(Ingress 無効 + `service.type: LoadBalancer` + kube-vip VIP + `loadBalancerSourceRanges`
によるローカルセグメント限定 + `anthropic.authMode: oauth`)。
## ドキュメント
- README に **「Claude 認証(API 課金なし / サブスクリプション)」** セクションを追加。
`claude setup-token` で発行した長期 OAuth トークンを `anthropic.oauthToken`
(`authMode: oauth`)に渡すと、API 従量課金を使わずローカルの Claude Code と同一の
挙動で動作する。トークンはセッション NS の Secret に保存し、セッション Pod へ
`CLAUDE_CODE_OAUTH_TOKEN` として注入。
- README に **「管理画面へのアクセス(LoadBalancer / Ingress)」** セクションを追加。
## バージョン
`version` = `2.1.263-a` / `appVersion` = `"2.1.263"`(主アプリ = Claude Code CLI は据え置き、
チャートのみ手動リリース)。
+46
View File
@@ -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"`。
+33
View File
@@ -0,0 +1,33 @@
# CHANGELOG 2.1.263-c — 手動リリース(ホットフィックス)
## 修正
- **gateway の nginx が起動時にクラッシュループする不具合**
```
nginx: [emerg] pcre2_compile() failed: missing closing parenthesis
in "^/s/(?<sid>[a-f0-9]" in /tmp/nginx/nginx.conf
```
`files/gateway.conf.template` の
```nginx
location ~ ^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$ {
```
で、正規表現中の `{10}` に含まれる `{` `}` を nginx の設定パーサーがブロック区切りと
解釈し、`location` の正規表現が `^/s/(?<sid>[a-f0-9]` で途切れていた。
nginx は `{` `}` `;` を含む正規表現を **クォート必須** とする。
修正: 正規表現全体をダブルクォートで囲む。
```nginx
location ~ "^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$" {
```
`envsubst` の置換対象は `${GATEWAY_PORT}` など 6 変数のみで `{10}` には影響しないことを
確認済み(レンダリング後のブレース対応もローカル検証)。
## バージョン
`version` = `2.1.263-c` / `appVersion` = `"2.1.263"`。
+38
View File
@@ -0,0 +1,38 @@
# CHANGELOG 2.1.263-d — 手動リリース
## 背景
セッション Pod がブート失敗(`git clone` エラー等)で `Failed` になると、reaper が
即座に削除するため `kubectl logs` で死因を確認できなかった。「開く」で 502 になるだけで
原因が分からない状態だった。
## 追加 / 変更
- **`session.reaper.terminatedRetentionSeconds`(既定 900)**
終了済み(Succeeded / Failed)のセッション Pod を、この秒数が経過するまで回収しない。
Pod の `containerStatuses[].state.terminated.finishedAt` を基準に判定。`0` で旧挙動(即回収)。
manager の回収ループと予備 CronJob の両方が尊重する。
- **`GET /api/sessions/<id>/log`**
セッション Pod(`claude` コンテナ)の直近ログを text/plain で返す。manager は既に
`pods/log` の `get` 権限を持つ(追加権限なし)。
- **Web UI**
- セッション一覧に **「ログ」ボタン**(インラインでログ表示)。
- 状態を色分け表示。`Failed` の行に `reason` / `exit <code>` / `message` を併記。
- `Running` 以外では「開く」を出さない。
- **セッションブートスクリプト(`files/session-entrypoint.sh`)**
- URL のスキーム(`https://` / `git@` / `ssh://`)と選択された identity の型
(`ssh` / `https-token`)の不一致を検出して警告。
- `git clone` を `--progress ... 2>&1` で実行し、git / ssh の出力をそのまま Pod ログへ。
失敗時は代表的な原因(認証不足・権限なし・known_hosts 不一致・NetworkPolicy による
egress 遮断)をメッセージで提示。
## トラブルシューティング追記(README)
- 「開く」で 502 / 一覧が `Failed` → UI の「ログ」ボタンで死因確認。
- clone が 60〜130 秒でタイムアウト → NetworkPolicy(Cilium 等)の egress 遮断を疑い、
`--set networkPolicy.enabled=false` で切り分け。
- URL スキームと identity 型を一致させる(HTTPS ↔ `https-token` / SSH ↔ `ssh`)。
## バージョン
`version` = `2.1.263-d` / `appVersion` = `"2.1.263"`。
+30
View File
@@ -0,0 +1,30 @@
# CHANGELOG 2.1.263-e — 手動リリース
## 背景
`claude@192.168.9.65:websites/e-stay-tours.git`(SSH ユーザーを自分のアカウント名にした URL)が
`リポジトリ URL の形式が不正です` で弾かれ、理由が分かりにくかった。
Gitea・GitHub の SSH ユーザーは常に `git` だが、それを案内していなかった。
## 変更
- **`REPO_RE`(manager の URL バリデーション)を緩和**
- `ssh://<user>@<host>[:port]/<path>` … `git` 以外のユーザーも受理
- `<user>@<host>:<path>` … SCP 記法。`git@` 固定をやめて任意ユーザー可
- `<host>:<path>` … ユーザー省略も受理
- `//` で始まるパスは引き続き拒否(URL 誤入力の検出)
- ホスト allowlist(`git.allowedHosts`)は従来どおり全形式に適用
- **エラーメッセージを具体化**
- URL 形式エラー時に受理される 3 形式と「SSH ユーザーは常に `git`」を明記
- ホスト不許可時に許可済みホスト一覧を併記
- **Web UI**: リポジトリ URL 入力欄の下に形式ヒントを表示
## 注意
`claude@…` のような不正ユーザーでも URL 形式としては通るようになったが、その場合の
`git clone` は Gitea 側で失敗する。失敗ログは `2.1.263-d` で追加した「ログ」ボタンで確認できる。
正しくは `git@<host>:<org>/<repo>.git`。
## バージョン
`version` = `2.1.263-e` / `appVersion` = `"2.1.263"`。
+36
View File
@@ -0,0 +1,36 @@
# CHANGELOG 2.1.263-f — 手動リリース
## 追加: GUI からの git identity 登録
Web UI の **「認証情報(identity)」カード** から、`helm` を触らずに SSH 秘密鍵 +
`known_hosts` / HTTPS トークンを登録・削除できるようにした。
- `git.guiIdentities.enabled`(既定 `true`)
- API:
- `GET /api/identities` … Helm 管理 + GUI 登録の identity 一覧(`{name, type, source}`)
- `POST /api/identities` … 登録(`{name, type, privateKey, knownHosts}` または
`{name, type, username, token}`)。`ssh` は `known_hosts` 必須、秘密鍵の簡易検証あり。
Helm 管理と同名は拒否。既存 GUI 分は置き換え。
- `DELETE /api/identities/<name>` … 削除(`identity-source=gui` のみ。Helm 管理は不可)
- 保存先: セッション NS の Secret。ラベル
`claude-code.cafepieters.com/identity-source: gui|helm` /
`claude-code.cafepieters.com/identity-type: ssh|https-token` で区別。
- `build_pod` / セッション作成のバリデーションは Helm 管理と GUI 登録の両方を解決する
(`resolve_identity` / `list_identities`)。
- `/api/config` の `identities` は文字列配列 → `{name,type,source}` 配列に変更。UI も追従。
## RBAC
`git.guiIdentities.enabled: true` のとき、manager Role にセッション NS の
`secrets` `["get","list","create","update","patch","delete"]` を追加(`false` なら付かない)。
同 NS には Claude 認証情報 Secret も存在するため、不要なら無効化を推奨。
## セキュリティ注意
秘密鍵はブラウザ → gateway → manager をリクエストボディで通る。経路の TLS
(Ingress の `tls` / LB の TLS 終端)を有効にすること。README「git — 接続先と認証
アイデンティティ」に明記。
## バージョン
`version` = `2.1.263-f` / `appVersion` = `"2.1.263"`。
+119
View File
@@ -57,6 +57,125 @@ Git リポジトリ(GitHub / Gitea など互換 Git 全般)をチェック
## 改修履歴 ## 改修履歴
### GUI からの git identity 登録(2026-09-09, v2.1.263-f)
**要望**: SSH 鍵を `helm --set-file` ではなく Web UI から登録したい。
**修正内容**:
- `git.guiIdentities.enabled`(既定 `true`)。UI に「認証情報」カード(一覧 + 追加フォーム +
削除)。`GET/POST/DELETE /api/identities`。
- 保存先はセッション NS の Secret(ラベル `identity-source: gui|helm` /
`identity-type: ssh|https-token`)。`secret-git.yaml` の Helm 管理分にも同ラベルを付与。
- `manager.py`: `resolve_identity` / `list_identities` / `create_gui_identity` /
`delete_gui_identity` を追加。`build_pod` と `create_session` は Helm 管理 + GUI 登録の
両方を解決。`/api/config` の `identities` を `{name,type,source}` 配列に変更(UI も追従)。
- `rbac.yaml`: `guiIdentities.enabled` 時のみ manager Role に同 NS `secrets` の
get/list/create/update/patch/delete を追加。
- 秘密鍵はリクエストボディで manager へ届く → README に「経路の TLS 必須」を明記。
**対象ファイル**: `files/manager.py`, `values.yaml`, `values.schema.json`,
`templates/rbac.yaml`, `templates/secret-git.yaml`, `templates/deployment.yaml`,
`README.md`, `Chart.yaml`, `CHANGELOG-2.1.263-f.md`
### リポジトリ URL バリデーション緩和 + エラーメッセージ具体化(2026-09-09, v2.1.263-e)
**症状**: `claude@192.168.9.65:...`(SSH ユーザーをアカウント名にした URL)が
`リポジトリ URL の形式が不正です` で弾かれ、原因が分かりにくい。Gitea/GitHub の SSH
ユーザーは常に `git`。
**修正内容**: `manager.py` の `REPO_RE` を `ssh://<user>@host/…` / `<user>@host:path` /
`host:path` も受理するよう緩和(`//` 始まりは拒否、ホスト allowlist は全形式に適用)。
URL 形式エラーで受理形式と「SSH ユーザーは常に `git`」を明記、ホスト不許可で許可済み
一覧を併記。UI 入力欄にヒント追加。README の git セクションに URL 形式表を追加。
**対象ファイル**: `files/manager.py`, `README.md`, `Chart.yaml`, `CHANGELOG-2.1.263-e.md`
### 失敗セッションの調査性改善(終了 Pod の保持 + ログ API + UI)(2026-09-08, v2.1.263-d)
**症状**: セッション Pod がブート失敗で `Failed` になると reaper が即削除し、`kubectl logs` で
死因を追えない。UI では「開く」で 502 になるだけ。
**修正内容**:
- `session.reaper.terminatedRetentionSeconds`(既定 900)を追加。終了済み Pod を
`finishedAt` からこの秒数は残す。`manager.py` の `reap_once` と reaper CronJob の両方が尊重。
- `GET /api/sessions/<id>/log`(`k8s_text` で pods/log を text 取得。追加 RBAC 不要)。
- UI: 「ログ」ボタン、状態の色分け、`Failed` 行に reason/exitCode/message 併記、
`Running` 以外は「開く」非表示。
- `session-entrypoint.sh`: URL スキームと identity 型の不一致を警告。`git clone` を
`--progress ... 2>&1` にして git/ssh 出力を Pod ログへ。
**対象ファイル**: `files/manager.py`, `files/session-entrypoint.sh`, `values.yaml`,
`values.schema.json`, `templates/deployment.yaml`, `templates/cronjob-reaper.yaml`,
`README.md`, `Chart.yaml`, `CHANGELOG-2.1.263-d.md`
### gateway nginx クラッシュループ修正(location 正規表現のクォート)(2026-09-08, v2.1.263-c)
**症状**: gateway コンテナが起動直後に
`nginx: [emerg] pcre2_compile() failed: missing closing parenthesis in "^/s/(?<sid>[a-f0-9]"`
で終了し、CrashLoopBackOff。
**原因**: `files/gateway.conf.template` の
`location ~ ^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$ { ... }` で、正規表現中の `{10}` の
`{` `}` を nginx の設定パーサーがブロック区切りと解釈し、`location` の regex が
`^/s/(?<sid>[a-f0-9]` で切れていた(nginx は `{` `}` `;` を含む正規表現のクォートを必須とする)。
**修正内容**: `location ~ "^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$" { ... }` と
正規表現全体をダブルクォートで囲んだ。envsubst の対象は `${...}` の 6 変数のみで
`{10}` には影響しないことを確認済み。
**対象ファイル**: `files/gateway.conf.template`, `README.md`, `Chart.yaml`, `CHANGELOG-2.1.263-c.md`
### 認証情報の 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 で
管理画面へアクセスしたい、という運用要望。あわせて「Claude の API 課金を使わず、ローカルの
Claude Code と同様にサブスクリプションで使いたい」という確認があった。
**修正内容**:
- `templates/service.yaml` を書き換え、`service.type: LoadBalancer` を既定化。
`service.annotations`(kube-vip の `kube-vip.io/loadbalancerIPs` / `vipHost` 等)、
`loadBalancerIP` / `loadBalancerClass` / `loadBalancerSourceRanges` /
`externalTrafficPolicy` / `nodePort` / `labels` を追加。Ingress 機能は変更なし(併用可)。
- `values.yaml` の `service:` ブロック拡張、`values.schema.json` に対応プロパティ追加。
- `templates/NOTES.txt` に LoadBalancer の EXTERNAL-IP 案内を追加。
- `examples/loadbalancer-oauth.values.yaml` を追加(Ingress 無効 + LB + source range +
`authMode: oauth`)。
- README に「Claude 認証(API 課金なし / サブスクリプション)」「管理画面へのアクセス
(LoadBalancer / Ingress)」セクションを追加。サブスクリプション認証は
`claude setup-token` → `anthropic.authMode: oauth`(`CLAUDE_CODE_OAUTH_TOKEN` を
セッション Pod に注入)で、ローカル実行の Claude Code と同一挙動。
**対象ファイル**: `templates/service.yaml`, `values.yaml`, `values.schema.json`,
`templates/NOTES.txt`, `examples/loadbalancer-oauth.values.yaml`, `README.md`, `Chart.yaml`,
`CHANGELOG-2.1.263-a.md`
### 初版(2026-09-03, v2.1.259) ### 初版(2026-09-03, v2.1.259)
チャート新規作成。gateway + manager + 使い捨て session Pod + reaper CronJob の構成で、 チャート新規作成。gateway + manager + 使い捨て session Pod + reaper CronJob の構成で、
+2 -2
View File
@@ -6,8 +6,8 @@ description: >-
使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・ 使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・
検証・プッシュする。一定時間アイドルなセッションは自動破棄される。 検証・プッシュする。一定時間アイドルなセッションは自動破棄される。
type: application type: application
version: 2.1.263 version: 2.1.283
appVersion: "2.1.263" appVersion: "2.1.283"
keywords: keywords:
- claude-code - claude-code
- claude - claude
+213 -17
View File
@@ -21,6 +21,8 @@ Raspberry Pi などの ARM ベアメタル Kubernetes を主な対象にして
- [アーキテクチャ](#アーキテクチャ) - [アーキテクチャ](#アーキテクチャ)
- [セッションのライフサイクル](#セッションのライフサイクル) - [セッションのライフサイクル](#セッションのライフサイクル)
- [セキュリティモデル](#セキュリティモデル) - [セキュリティモデル](#セキュリティモデル)
- [Claude 認証(API 課金なし / サブスクリプション)](#claude-認証api-課金なし--サブスクリプション)
- [管理画面へのアクセス(LoadBalancer / Ingress)](#管理画面へのアクセスloadbalancer--ingress)
- [インストール](#インストール) - [インストール](#インストール)
- [使い方](#使い方) - [使い方](#使い方)
- [values リファレンス](#values-リファレンス) - [values リファレンス](#values-リファレンス)
@@ -32,7 +34,7 @@ Raspberry Pi などの ARM ベアメタル Kubernetes を主な対象にして
- [git — 接続先と認証アイデンティティ](#git--接続先と認証アイデンティティ) - [git — 接続先と認証アイデンティティ](#git--接続先と認証アイデンティティ)
- [session — 使い捨てセッション](#session--使い捨てセッション) - [session — 使い捨てセッション](#session--使い捨てセッション)
- [networkPolicy](#networkpolicy) - [networkPolicy](#networkpolicy)
- [ingress / service](#ingress--service) - [service / ingress](#ingress--service)
- [セッションイメージ](#セッションイメージ) - [セッションイメージ](#セッションイメージ)
- [運用](#運用) - [運用](#運用)
- [トラブルシューティング](#トラブルシューティング) - [トラブルシューティング](#トラブルシューティング)
@@ -111,13 +113,143 @@ manager / gateway / session-entrypoint の実体は `files/` にあり、`.Files
> **注意** — `values.yaml` に API キーや秘密鍵を書いてコミットしないでください。`helm get values` / > **注意** — `values.yaml` に API キーや秘密鍵を書いてコミットしないでください。`helm get values` /
> CI ログ / Git 履歴に残ります。`existingSecret` か CI からの `--set` を使ってください。 > CI ログ / Git 履歴に残ります。`existingSecret` か CI からの `--set` を使ってください。
## Claude 認証(API 課金なし / サブスクリプション)
このチャートは **Claude の API(従量課金)を使わずに**、ローカルの Claude Code と同じ
**サブスクリプション認証(Pro / Max プラン)** で動かせます。セッション内の `claude` の
挙動・機能はローカル実行と同一です(同じ CLI・同じログイン方式)。
`anthropic.authMode` は 3 つ:
| モード | 注入するもの | トークン更新 | 課金 |
|---|---|---|---|
| **`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 従量課金 |
### `credentials` モードの使い方(推奨)
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-file anthropic.credentialsJson="$HOME/.claude/.credentials.json"
```
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-creds \
--from-file=.credentials.json="$HOME/.claude/.credentials.json"
# values: anthropic.existingSecret=claude-creds
# (refresher に patch させるため RBAC はこの Secret 名に限定して付与される)
```
### 注意 / 制約
- 利用枠はローカルの 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)
管理画面(gateway)への到達経路は 2 つあり、**併用できます**。既定は LoadBalancer です。
### A. LoadBalancer でローカルネットワークの IP を使う(既定・Ingress 不要)
`service.type: LoadBalancer`(既定)にすると、クラスタの LB 実装
(**kube-vip** / MetalLB / Cilium LB-IPAM など)がローカルネットワークの IP を払い出します。
その IP をブラウザで開くだけでアクセスできます。ドメインも Ingress も TLS 終端も不要です。
```yaml
ingress:
enabled: false # Ingress は張らない
service:
type: LoadBalancer
port: 80
annotations:
kube-vip.io/loadbalancerIPs: "192.168.9.50" # 払い出す VIP(空きアドレス)
kube-vip.io/vipHost: "cpi11"
loadBalancerSourceRanges: # 接続元をローカルセグメントに限定(第一の防御線)
- 192.168.9.0/24
externalTrafficPolicy: Local # 実クライアント IP を保持
```
```bash
kubectl -n claude-code get svc claude-code # EXTERNAL-IP を確認
# → http://<EXTERNAL-IP>/ をブラウザで開く
```
| キー | 説明 |
|---|---|
| `service.type` | `LoadBalancer`(既定)/ `ClusterIP` / `NodePort` |
| `service.port` | 待受ポート(既定 `80`) |
| `service.annotations` | LB 実装へのヒント(kube-vip の VIP 指定、MetalLB のプール指定など) |
| `service.loadBalancerIP` | 払い出す IP を直接指定(annotations 指定を推奨) |
| `service.loadBalancerSourceRanges` | 接続を許可する CIDR。**Ingress を張らない構成では必ず設定して接続元を絞る** |
| `service.loadBalancerClass` | 複数 LB 実装がある場合の指定 |
| `service.externalTrafficPolicy` | `Local` で実クライアント IP を保持 |
| `service.nodePort` | `type: NodePort` 時の固定ポート |
> **セキュリティ** — LoadBalancer は L3/L4 でネットワークに口を開けます。`loadBalancerSourceRanges`
> で接続元を絞り、`auth.mode` は `token`(既定)以上にしてください。VIP セグメントは
> ルーターの DHCP 範囲と重複させないこと。
### B. Ingress でドメインアクセス(従来どおり維持)
`ingress.enabled: true` で Ingress を作成します。A と同時に有効化しても構いません。
WebSocket 用に `proxy-read-timeout` / `proxy-send-timeout` を既定 `3600` に設定します
(`ingress.annotations` で上書き可)。TLS は `ingress.tls` で設定します。
```yaml
ingress:
enabled: true
className: nginx
hosts:
- host: claude-code.example.com
paths: [{ path: /, pathType: Prefix }]
tls:
- secretName: claude-code-tls
hosts: [claude-code.example.com]
```
## インストール ## インストール
### 前提 ### 前提
- Kubernetes 1.25+(PodSecurity admission)、NetworkPolicy 対応 CNI(Calico / Cilium など) - Kubernetes 1.25+(PodSecurity admission)、NetworkPolicy 対応 CNI(Calico / Cilium など)
- Ingress コントローラー(外部公開する場合。WebSocket を通すため read/send timeout を延長) - 管理画面の公開方法に応じて **どちらか**:
- `ANTHROPIC_API_KEY`(または `claude setup-token` で発行した OAuth トークン) - LoadBalancer 実装(kube-vip / MetalLB / Cilium LB-IPAM など) … 既定
- Ingress コントローラー … `ingress.enabled: true` の場合
- どちらも無い場合は `service.type: ClusterIP` + `kubectl port-forward` で利用可
- Claude の認証情報 … `~/.claude/.credentials.json`(既定 `credentials` モード・推奨)
または `claude setup-token` の OAuth トークン、`ANTHROPIC_API_KEY`
### Helm リポジトリから ### Helm リポジトリから
@@ -130,6 +262,23 @@ helm upgrade --install claude-code cafepieters/claude-code \
--set anthropic.apiKey="$ANTHROPIC_API_KEY" --set anthropic.apiKey="$ANTHROPIC_API_KEY"
``` ```
### ローカル LB の IP + サブスクリプション認証(API 不要・推奨構成)
```bash
# 手元の 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-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` によるローカルセグメント限定の構成です。
### ローカルチャートから(GitHub 公開リポジトリだけ触る最小構成) ### ローカルチャートから(GitHub 公開リポジトリだけ触る最小構成)
```bash ```bash
@@ -159,11 +308,11 @@ ssh-keyscan 192.168.9.65 > known_hosts.gitea
## 使い方 ## 使い方
1. アクセス(Ingress 無効ならポートフォワード): 1. 管理画面を開く:
- **LoadBalancer(既定)**: `kubectl -n claude-code get svc claude-code` の `EXTERNAL-IP` を
```bash ブラウザで開く(`http://<IP>/`)
kubectl -n claude-code port-forward svc/claude-code 8080:80 - **Ingress**: 設定したドメイン(`https://claude-code.example.com/`)
``` - **どちらも無効**: `kubectl -n claude-code port-forward svc/claude-code 8080:80` → `http://localhost:8080/`
2. `auth.mode: token` の場合、トークンを取得して UI に貼り付け(または `?token=` で開く): 2. `auth.mode: token` の場合、トークンを取得して UI に貼り付け(または `?token=` で開く):
@@ -227,14 +376,23 @@ ssh-keyscan 192.168.9.65 > known_hosts.gitea
### anthropic — Claude Code 認証情報 ### 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.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 切替など) | | `anthropic.extraEnv` | `{}` | 全セッションへ渡す追加 env(例 `ANTHROPIC_BASE_URL`、Bedrock/Vertex 切替など) |
Secret は **セッション NS** に作成され、セッション Pod へ `envFrom` で注入されます。 Secret は **セッション NS** に作成されます。`credentials` モードはファイルとしてマウントし
セッション起動時に `$HOME/.claude/.credentials.json` へコピー(書き込み可能)、
`apiKey` / `oauth` モードは `envFrom` で環境変数として注入します。
### git — 接続先と認証アイデンティティ ### git — 接続先と認証アイデンティティ
@@ -243,7 +401,8 @@ Secret は **セッション NS** に作成され、セッション Pod へ `env
| `git.allowedHosts` | GitHub/GitLab/Gitea | clone を許可するホスト allow-list | | `git.allowedHosts` | GitHub/GitLab/Gitea | clone を許可するホスト allow-list |
| `git.allowArbitraryRepos` | `false` | allow-list を無効化し任意 URL を許可(非推奨) | | `git.allowArbitraryRepos` | `false` | allow-list を無効化し任意 URL を許可(非推奨) |
| `git.defaultAuthorName` / `Email` | `Claude` / `claude@cafepieters.com` | コミット author 既定 | | `git.defaultAuthorName` / `Email` | `Claude` / `claude@cafepieters.com` | コミット author 既定 |
| `git.identities.<name>.type` | — | `ssh` / `https-token` | | `git.guiIdentities.enabled` | `true` | **Web UI から identity を登録・削除できるようにする**(下記) |
| `git.identities.<name>.type` | — | `ssh` / `https-token`(Helm 管理・UI では読み取り専用) |
| `…ssh.sshPrivateKey` / `knownHosts` | — | PEM 秘密鍵 / `ssh-keyscan` 出力(**`knownHosts` 必須**) | | `…ssh.sshPrivateKey` / `knownHosts` | — | PEM 秘密鍵 / `ssh-keyscan` 出力(**`knownHosts` 必須**) |
| `…https-token.username` / `token` | `x-access-token` / — | HTTPS ベーシック(GitHub PAT は username 任意) | | `…https-token.username` / `token` | `x-access-token` / — | HTTPS ベーシック(GitHub PAT は username 任意) |
| `…existingSecret` ほか | — | 既存 Secret 参照(キー名は `existingSecretKey*` で調整) | | `…existingSecret` ほか | — | 既存 Secret 参照(キー名は `existingSecretKey*` で調整) |
@@ -251,6 +410,32 @@ Secret は **セッション NS** に作成され、セッション Pod へ `env
GitHub でも Gitea でも GitLab でも、SSH か HTTPS トークンで認証できる Git ホストなら同じ仕組みで扱えます。 GitHub でも Gitea でも GitLab でも、SSH か HTTPS トークンで認証できる Git ホストなら同じ仕組みで扱えます。
セッション作成時に identity 名を選ぶと、その Secret だけがそのセッション Pod にマウントされます。 セッション作成時に identity 名を選ぶと、その Secret だけがそのセッション Pod にマウントされます。
#### GUI からの identity 登録(`git.guiIdentities.enabled: true`)
Web UI の **「認証情報(identity)」カード** から、SSH 秘密鍵 + `known_hosts`、または
HTTPS トークンを登録・削除できます。`helm` を触らずにセッションで使う鍵を追加できます。
- 登録内容はセッション NS の Secret(ラベル `claude-code.cafepieters.com/identity-source: gui`)
として保存され、`git.identities`(Helm 管理・`identity-source: helm`)と同じ一覧に並びます。
Helm 管理のものは UI から削除できません。
- **セキュリティ**: 秘密鍵はブラウザ → gateway → manager をリクエストボディで通ります。
**経路の TLS(Ingress の `tls` / LB の TLS 終端)を必ず有効に**してください。
有効化すると manager にセッション NS の `secrets` への `get`/`list`/`create`/`update`/`delete`
権限が付きます(同 NS には Claude 認証情報 Secret も存在)。不要なら `enabled: false`。
- `known_hosts` は登録時必須です(`ssh-keyscan <host>` の出力を貼り付け。ホスト鍵 TOFU 無効化)。
**URL の形式**(`git.identities` で選んだ鍵で認証されます):
| 形式 | 例 | identity |
|---|---|---|
| SSH(SCP 記法) | `git@192.168.9.65:websites/e-stay-tours.git` | `ssh` 型 |
| SSH(URL 記法) | `ssh://git@192.168.9.65/websites/e-stay-tours.git` | `ssh` 型 |
| HTTPS | `https://git.cafepieters.com/websites/e-stay-tours.git` | `https-token` 型(プライベート時) |
> SSH のユーザー名は **常に `git`** です(Gitea・GitHub 共通)。自分の Gitea アカウント名
> (`claude@…` など)ではありません。認証は identity の鍵で行われるため、その鍵を持つ
> アカウントが対象リポジトリの参照権限を持っている必要があります。
### session — 使い捨てセッション ### session — 使い捨てセッション
| キー | 既定 | 説明 | | キー | 既定 | 説明 |
@@ -288,9 +473,8 @@ GitHub でも Gitea でも GitLab でも、SSH か HTTPS トークンで認証
### ingress / service ### ingress / service
`ingress.enabled: true` で Ingress を作成。WebSocket 用に `proxy-read-timeout` / → [管理画面へのアクセス(LoadBalancer / Ingress)](#管理画面へのアクセスloadbalancer--ingress) を参照。
`proxy-send-timeout` を既定 `3600` に設定します(`ingress.annotations` で上書き可)。 `service.type` の既定は `LoadBalancer`、`ingress.enabled` の既定は `false`(両者は併用可)。
`service.type` は `ClusterIP` 既定。
## セッションイメージ ## セッションイメージ
@@ -332,10 +516,13 @@ docker buildx build --platform linux/amd64,linux/arm64 \
| 症状 | 原因と対処 | | 症状 | 原因と対処 |
|---|---| |---|---|
| UI が 502 / セッションを開くと 502 | セッション Pod がまだ Ready でない(初回は `npm i` に時間)。数十秒待つ。恒久対策は事前ビルドイメージ | | セッションを「開く」と 502 / 一覧で `Failed` になる | セッション Pod のブートが失敗している。UI の **「ログ」ボタン**(または `kubectl logs -n <sessionsNamespace> claude-sess-<id>`)で死因を確認。終了 Pod は `session.reaper.terminatedRetentionSeconds`(既定 900s)残る |
| clone が ~60〜130 秒後にタイムアウトして `Failed` | セッション Pod から Git ホストへの egress が届いていない。多くは **NetworkPolicy(Cilium 等)** が `ipBlock` egress を通していない。`--set networkPolicy.enabled=false` で切り分け。恒久対応は `networkPolicy.sessionEgressExtra` に対象 CIDR を追加、または Cilium 環境なら egress ルールの調整 |
| clone が `could not read Username` / `Authentication failed` | プライベートリポジトリに認証情報が無い。HTTPS URL には `https-token` identity(Gitea/GitHub の PAT)を、SSH URL(`git@host:...` / `ssh://git@host/...`)には `ssh` identity を選ぶ。**URL のスキームと identity の型を一致させる** |
| clone が `repository ... not found` / `does not appear to be a git repository` | パス誤り、またはその identity のアカウントに対象リポジトリの参照権限が無い |
| UI が 502(`/s/...` のログが gateway に出ない) | 前段(LB / Ingress)が実体の無いセッションに返している。まず一覧で該当セッションが `Running` か、上記でブート失敗していないか確認 |
| セッション作成が `許可されていない Git ホスト` | `git.allowedHosts` に対象ホストを追加(IP と FQDN は別物) | | セッション作成が `許可されていない Git ホスト` | `git.allowedHosts` に対象ホストを追加(IP と FQDN は別物) |
| clone が `Host key verification failed` | identity の `knownHosts` が対象ホストの鍵と不一致。`ssh-keyscan <host>` で取り直す | | 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 からログイン | | WebSocket がすぐ切れる | Ingress の `proxy-read-timeout` を延長。`auth.mode: token` で Cookie 未設定なら一度 UI からログイン |
| セッションが破棄されない | manager の RBAC / ログを確認。予備 CronJob(`session.reaper.cronjob`)が有効か | | セッションが破棄されない | manager の RBAC / ログを確認。予備 CronJob(`session.reaper.cronjob`)が有効か |
| `apk` エラーがログに出る | 既定イメージ + `readOnlyRootFilesystem` の制約。事前ビルドイメージを使う | | `apk` エラーがログに出る | 既定イメージ + `readOnlyRootFilesystem` の制約。事前ビルドイメージを使う |
@@ -368,5 +555,14 @@ docker buildx build --platform linux/amd64,linux/arm64 \
| バージョン | Claude Code | 変更内容 | | バージョン | Claude Code | 変更内容 |
|---|---|---| |---|---|---|
| 2.1.283 | 2.1.283 | 自動更新 |
| 2.1.278 | 2.1.278 | 自動更新 |
| 2.1.270 | 2.1.270 | 自動更新 |
| 2.1.263-f | 2.1.263 | **Web UI から git identity(SSH 秘密鍵 + known_hosts / HTTPS トークン)を登録・削除**できる「認証情報」カードを追加(`git.guiIdentities.enabled`、既定 `true`)。`GET/POST/DELETE /api/identities`。GUI 登録分はセッション NS の Secret(`identity-source: gui`)として保存し、Helm 管理分と同じ一覧に統合。manager RBAC に同 NS の `secrets` CRUD を条件付き付与 |
| 2.1.263-e | 2.1.263 | リポジトリ URL バリデーションを緩和(`ssh://user@…` / `user@host:path` / `host:path` も受理。ホスト allowlist は従来どおり適用)。エラーメッセージに受理形式と「SSH ユーザーは常に `git`」の注意を明記。UI の入力欄にヒントを追加 |
| 2.1.263-d | 2.1.263 | 失敗したセッションの調査性を改善。`session.reaper.terminatedRetentionSeconds`(既定 900)で終了 Pod を即削除せず保持。UI に **「ログ」ボタン**(`GET /api/sessions/<id>/log`)を追加し、`Failed` の理由(exitCode / reason)を一覧に表示。セッションブートスクリプトが URL スキームと identity 型の不一致を警告し、`git clone` の出力をそのままログに出すよう変更 |
| 2.1.263-c | 2.1.263 | 修正: gateway の nginx が起動時 `pcre2_compile() failed: missing closing parenthesis` でクラッシュループ。`location ~` の正規表現に `{10}` が含まれ、nginx がブロック区切りの `{}` と解釈していた。正規表現全体をダブルクォートで囲んで解消 |
| 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.263 | 2.1.263 | 自動更新 |
| 2.1.259 | 2.1.259 | 初版。gateway + manager + 使い捨て session Pod + reaper、GitHub/Gitea 対応、アイドル自動破棄、NetworkPolicy / RBAC 最小化 / restricted PodSecurity | | 2.1.259 | 2.1.259 | 初版。gateway + manager + 使い捨て session Pod + reaper、GitHub/Gitea 対応、アイドル自動破棄、NetworkPolicy / RBAC 最小化 / restricted PodSecurity |
+66
View File
@@ -0,0 +1,66 @@
# ローカルネットワーク限定の LoadBalancer で公開し、Claude のサブスクリプション
# (API 課金なし)で動かす構成。Ingress は張らない。
#
# 前提:
# - クラスタに LB 実装(kube-vip / MetalLB / Cilium LB-IPAM 等)がある
# - 手元の 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-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: credentials # ~/.claude/.credentials.json 全体を注入(refreshToken 込み)
# credentialsJson は --set-file で注入する(平文で書かない)
refresher:
enabled: true # クラスタ内で accessToken を定期更新し Secret を書き戻す
schedule: "*/30 * * * *"
# ---- 管理画面はローカル LB の IP でアクセス(Ingress なし)----
ingress:
enabled: false
service:
type: LoadBalancer
port: 80
annotations:
# kube-vip で払い出す VIP を固定(この 192.168.9.x は空いているものを指定)
kube-vip.io/loadbalancerIPs: "192.168.9.50"
kube-vip.io/vipHost: "cpi11"
# 接続元をローカルセグメントに限定(Ingress を張らない構成での第一の防御線)
loadBalancerSourceRanges:
- 192.168.9.0/24
externalTrafficPolicy: Local # 実クライアント IP を保持(LB 実装が対応する場合)
# ---- ブラウザアクセスの認証: トークン(自動生成)----
auth:
mode: token
# token.value を空にすると install 時に自動生成される。
# 取得: kubectl -n claude-code get secret claude-code-auth -o jsonpath='{.data.token}' | base64 -d
# ---- Git 接続先 ----
git:
allowedHosts:
- github.com
- 192.168.9.65
- git.cafepieters.com
identities:
gitea-ssh:
type: ssh
# sshPrivateKey / knownHosts は --set-file で注入
# github-https:
# type: https-token
# username: "x-access-token"
session:
autoStartClaude: true
idleTimeoutSeconds: 1800
maxSessions: 5
# verifyCommand: "npm test"
# autoPush:
# enabled: true
+2 -1
View File
@@ -72,7 +72,8 @@ http {
} }
# ---- セッションへのリバースプロキシ(WebSocket 対応)---- # ---- セッションへのリバースプロキシ(WebSocket 対応)----
location ~ ^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$ { # 正規表現に { } を含むため location 全体をクォートする(nginx 必須)
location ~ "^/s/(?<sid>[a-f0-9]{10})(?<rest>/.*)?$" {
include /tmp/nginx/auth_basic.conf; include /tmp/nginx/auth_basic.conf;
include /tmp/nginx/auth_request.conf; include /tmp/nginx/auth_request.conf;
error_page 401 = @needlogin; error_page 401 = @needlogin;
+365 -26
View File
@@ -54,7 +54,11 @@ SESSION_IMAGE_PULL_POLICY = env("SESSION_IMAGE_PULL_POLICY", "IfNotPresent")
SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", []) SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", [])
BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session") BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session")
ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic") ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic")
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {}) ANTHROPIC_AUTH_MODE = env("ANTHROPIC_AUTH_MODE", "apiKey")
ANTHROPIC_CREDENTIALS_KEY = env("ANTHROPIC_CREDENTIALS_KEY", ".credentials.json")
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {}) # Helm 管理(読み取り専用)
GUI_IDENTITIES_ENABLED = env("GUI_IDENTITIES_ENABLED", "false").lower() == "true"
GIT_FULLNAME_PREFIX = env("GIT_FULLNAME_PREFIX", FULLNAME)
GIT_ALLOWED_HOSTS = [h.strip().lower() for h in env("GIT_ALLOWED_HOSTS", "").split(",") if h.strip()] 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_ALLOW_ARBITRARY = env("GIT_ALLOW_ARBITRARY", "false").lower() == "true"
GIT_AUTHOR_NAME = env("GIT_DEFAULT_AUTHOR_NAME", "Claude") GIT_AUTHOR_NAME = env("GIT_DEFAULT_AUTHOR_NAME", "Claude")
@@ -62,6 +66,9 @@ GIT_AUTHOR_EMAIL = env("GIT_DEFAULT_AUTHOR_EMAIL", "claude@cafepieters.com")
IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800")) IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800"))
MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200")) MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200"))
REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60")) REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60"))
# 終了済み(Succeeded / Failed)セッション Pod を残す秒数。0 なら即回収。
# 死因を kubectl logs / UI「ログ」で調べられるよう既定で猶予を持たせる。
TERMINATED_RETENTION = int(env("TERMINATED_RETENTION_SECONDS", "900"))
MAX_SESSIONS = int(env("MAX_SESSIONS", "10")) MAX_SESSIONS = int(env("MAX_SESSIONS", "10"))
TTYD_PORT = int(env("TTYD_PORT", "7681")) TTYD_PORT = int(env("TTYD_PORT", "7681"))
SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {}) SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {})
@@ -98,14 +105,26 @@ LABEL_COMPONENT = "app.kubernetes.io/component"
LABEL_SID = "claude-code.cafepieters.com/session-id" LABEL_SID = "claude-code.cafepieters.com/session-id"
ANN_PREFIX = "claude-code.cafepieters.com/" ANN_PREFIX = "claude-code.cafepieters.com/"
SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code" SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code"
LABEL_GIT_IDENT = "claude-code.cafepieters.com/git-identity"
LABEL_IDENT_SOURCE = "claude-code.cafepieters.com/identity-source"
LABEL_IDENT_TYPE = "claude-code.cafepieters.com/identity-type"
SID_RE = re.compile(r"^[a-f0-9]{10}$") SID_RE = re.compile(r"^[a-f0-9]{10}$")
# 受け入れる repo URL: https://host/... または ssh://git@host/... または git@host:path # identity 名は Secret 名の一部になるため DNS ラベル相当に制限
IDENT_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$")
# 受け入れる repo URL:
# https://host[:port]/path
# ssh://[user@]host[:port]/path
# [user@]host:path (SCP 形式。Gitea/GitHub の SSH ユーザーは常に "git")
_USER = r"(?:[A-Za-z0-9_.\-]+@)?"
REPO_RE = re.compile( REPO_RE = re.compile(
r"^(?:https://(?P<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+" r"^(?:https://(?P<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|ssh://git@(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+" r"|ssh://" + _USER + r"(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|git@(?P<h3>[A-Za-z0-9.\-]+):[\w./\-~%]+)$" r"|" + _USER + r"(?P<h3>[A-Za-z0-9.\-]+):(?!//)[\w./\-~%]+)$"
) )
REPO_HELP = ("URL の形式: https://<host>/<org>/<repo>.git / "
"ssh://git@<host>/<org>/<repo>.git / git@<host>:<org>/<repo>.git "
"(Gitea・GitHub の SSH ユーザーは常に 'git'。あなたのアカウント名ではありません)")
REF_RE = re.compile(r"^[\w./\-]{1,200}$") REF_RE = re.compile(r"^[\w./\-]{1,200}$")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -144,6 +163,154 @@ def k8s(method, path, body=None, ctype="application/json"):
def core(path): def core(path):
return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path
def k8s_text(path):
"""text/plain を返すエンドポイント(Pod ログ)用。(status, text) を返す。"""
req = urllib.request.Request(url=K8S_HOST + path, method="GET")
req.add_header("Authorization", "Bearer " + K8S_TOKEN)
try:
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", "replace")
def session_log(sid, tail=800):
if not SID_RE.match(sid):
return 400, "bad session id"
return k8s_text(core("/pods/claude-sess-" + sid + "/log?container=claude&tailLines=" + str(int(tail))))
# --------------------------------------------------------------------------
# git identity(Helm 管理 + GUI 登録)
# --------------------------------------------------------------------------
def gui_identity_secret(name):
return GIT_FULLNAME_PREFIX + "-git-" + name
def list_gui_identities():
"""GUI から登録された identity(source=gui ラベルの Secret)の一覧。"""
if not GUI_IDENTITIES_ENABLED:
return []
status, payload = k8s("GET", core("/secrets?labelSelector=" +
urllib.parse.quote(LABEL_IDENT_SOURCE + "=gui")))
if status != 200:
log.error("list identity secrets failed: %s", status)
return []
out = []
for sec in payload.get("items", []):
lbl = sec.get("metadata", {}).get("labels", {})
nm = lbl.get(LABEL_GIT_IDENT)
if nm:
out.append({"name": nm, "type": lbl.get(LABEL_IDENT_TYPE, "ssh"), "source": "gui"})
return out
def list_identities():
"""UI 用: Helm 管理 + GUI 登録の identity 一覧。"""
out = [{"name": n, "type": (v or {}).get("type", "ssh"), "source": "helm"}
for n, v in GIT_IDENTITIES.items()]
names = {i["name"] for i in out}
for i in list_gui_identities():
if i["name"] not in names:
out.append(i)
return sorted(out, key=lambda x: x["name"])
def resolve_identity(name):
"""build_pod 用にマウント情報を返す。無ければ None。"""
if not name:
return None
if name in GIT_IDENTITIES:
return GIT_IDENTITIES[name]
if not GUI_IDENTITIES_ENABLED:
return None
status, sec = k8s("GET", core("/secrets/" + gui_identity_secret(name)))
if status != 200:
return None
lbl = sec.get("metadata", {}).get("labels", {})
if lbl.get(LABEL_IDENT_SOURCE) != "gui":
return None
typ = lbl.get(LABEL_IDENT_TYPE, "ssh")
data = sec.get("data", {}) or {}
if typ == "https-token":
user = "x-access-token"
if data.get("username"):
try:
user = base64.b64decode(data["username"]).decode("utf-8").strip() or user
except Exception: # noqa: BLE001
pass
return {"type": "https-token", "secret": gui_identity_secret(name),
"keyToken": "token", "username": user}
return {"type": "ssh", "secret": gui_identity_secret(name),
"keyPrivateKey": "id", "keyKnownHosts": "known_hosts"}
def create_gui_identity(body):
if not GUI_IDENTITIES_ENABLED:
return 403, {"error": "GUI からの identity 登録は無効です(git.guiIdentities.enabled)"}
name = str(body.get("name", "")).strip()
typ = str(body.get("type", "")).strip()
if not IDENT_NAME_RE.match(name):
return 400, {"error": "identity 名は英小文字・数字・ハイフン(先頭末尾は英数字、40 文字以内)"}
if name in GIT_IDENTITIES:
return 409, {"error": "Helm 管理の identity と同名です: " + name}
if typ == "ssh":
key = str(body.get("privateKey", "")).strip()
kh = str(body.get("knownHosts", "")).strip()
if "PRIVATE KEY" not in key:
return 400, {"error": "privateKey が OpenSSH/PEM 秘密鍵ではありません"}
if not kh:
return 400, {"error": "knownHosts は必須です(`ssh-keyscan <host>` の出力)"}
string_data = {"id": key + ("\n" if not key.endswith("\n") else ""), "known_hosts": kh + "\n"}
elif typ == "https-token":
token = str(body.get("token", "")).strip()
if not token:
return 400, {"error": "token は必須です"}
string_data = {"username": str(body.get("username", "") or "x-access-token").strip(),
"token": token}
else:
return 400, {"error": "type は ssh か https-token"}
manifest = {
"apiVersion": "v1", "kind": "Secret", "type": "Opaque",
"metadata": {
"name": gui_identity_secret(name),
"namespace": SESSIONS_NS,
"labels": {
LABEL_APP: "claude-code",
LABEL_GIT_IDENT: name,
LABEL_IDENT_SOURCE: "gui",
LABEL_IDENT_TYPE: typ,
},
},
"stringData": string_data,
}
# 既存なら置き換え(PUT)、無ければ作成(POST)
status, _ = k8s("GET", core("/secrets/" + gui_identity_secret(name)))
if status == 200:
st, payload = k8s("PUT", core("/secrets/" + gui_identity_secret(name)), manifest)
else:
st, payload = k8s("POST", core("/secrets"), manifest)
if st not in (200, 201):
return st, {"error": "Secret 保存に失敗: %s" % payload.get("message", st)}
log.info("gui identity %s (%s) saved", name, typ)
return 200, {"ok": True, "name": name, "type": typ}
def delete_gui_identity(name):
if not GUI_IDENTITIES_ENABLED:
return 403, {"error": "無効です"}
if not IDENT_NAME_RE.match(name):
return 400, {"error": "bad name"}
status, sec = k8s("GET", core("/secrets/" + gui_identity_secret(name)))
if status != 200:
return 404, {"error": "見つかりません"}
if sec.get("metadata", {}).get("labels", {}).get(LABEL_IDENT_SOURCE) != "gui":
return 403, {"error": "Helm 管理の identity は GUI から削除できません"}
k8s("DELETE", core("/secrets/" + gui_identity_secret(name)))
log.info("gui identity %s deleted", name)
return 200, {"ok": True}
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# セッション操作 # セッション操作
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -164,7 +331,7 @@ def list_sessions():
meta = pod.get("metadata", {}) meta = pod.get("metadata", {})
ann = meta.get("annotations", {}) ann = meta.get("annotations", {})
st = pod.get("status", {}) st = pod.get("status", {})
out.append({ info = {
"id": meta.get("labels", {}).get(LABEL_SID, ""), "id": meta.get("labels", {}).get(LABEL_SID, ""),
"phase": st.get("phase", "Unknown"), "phase": st.get("phase", "Unknown"),
"repo": ann.get(ANN_PREFIX + "repo", ""), "repo": ann.get(ANN_PREFIX + "repo", ""),
@@ -173,10 +340,33 @@ def list_sessions():
"owner": ann.get(ANN_PREFIX + "owner", ""), "owner": ann.get(ANN_PREFIX + "owner", ""),
"createdAt": ann.get(ANN_PREFIX + "created-at", ""), "createdAt": ann.get(ANN_PREFIX + "created-at", ""),
"lastActivity": ann.get(ANN_PREFIX + "last-activity", ""), "lastActivity": ann.get(ANN_PREFIX + "last-activity", ""),
}) "reason": st.get("reason", ""), # DeadlineExceeded 等
"message": st.get("message", ""),
"finishedAt": "",
"exitCode": None,
}
for cs in st.get("containerStatuses", []) or []:
term = (cs.get("state") or {}).get("terminated")
if term:
info["finishedAt"] = term.get("finishedAt", "")
info["exitCode"] = term.get("exitCode")
if not info["reason"]:
info["reason"] = term.get("reason", "")
out.append(info)
return out return out
def _rfc3339_epoch(value):
"""RFC3339(...Z)→ epoch 秒。失敗時は 0。"""
if not value:
return 0
try:
import datetime
return int(datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
except Exception: # noqa: BLE001
return 0
def active_session_count(): def active_session_count():
return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running")) return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running"))
@@ -221,7 +411,7 @@ def build_pod(sid, repo, ref, identity_name, owner):
{"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True}, {"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True},
] ]
ident = GIT_IDENTITIES.get(identity_name) ident = resolve_identity(identity_name)
if ident: if ident:
env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")}) env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")})
if ident.get("type") == "ssh": if ident.get("type") == "ssh":
@@ -249,13 +439,33 @@ def build_pod(sid, repo, ref, identity_name, owner):
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True}) mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")}) 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 = { container = {
"name": "claude", "name": "claude",
"image": SESSION_IMAGE, "image": SESSION_IMAGE,
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY, "imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"], "command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
"env": env_list, "env": env_list,
"envFrom": [{"secretRef": {"name": ANTHROPIC_SECRET}}], "envFrom": env_from,
"ports": [{"name": "ttyd", "containerPort": TTYD_PORT}], "ports": [{"name": "ttyd", "containerPort": TTYD_PORT}],
"volumeMounts": mounts, "volumeMounts": mounts,
"resources": SESSION_RESOURCES, "resources": SESSION_RESOURCES,
@@ -346,13 +556,14 @@ def create_session(repo, ref, identity_name, owner):
repo = (repo or "").strip() repo = (repo or "").strip()
ref = (ref or "main").strip() ref = (ref or "main").strip()
if not REPO_RE.match(repo): if not REPO_RE.match(repo):
return None, "リポジトリ URL の形式が不正です" return None, "リポジトリ URL の形式が不正です。" + REPO_HELP
if not REF_RE.match(ref): if not REF_RE.match(ref):
return None, "ref の形式が不正です" return None, "ref(ブランチ / タグ)の形式が不正です"
host = repo_host(repo) host = repo_host(repo)
if not GIT_ALLOW_ARBITRARY and host not in GIT_ALLOWED_HOSTS: if not GIT_ALLOW_ARBITRARY and host not in GIT_ALLOWED_HOSTS:
return None, "許可されていない Git ホストです: " + str(host) return None, ("許可されていない Git ホストです: %s(許可済み: %s)"
if identity_name and identity_name not in GIT_IDENTITIES: % (host, ", ".join(GIT_ALLOWED_HOSTS) or "なし"))
if identity_name and resolve_identity(identity_name) is None:
return None, "未知の git identity: " + identity_name return None, "未知の git identity: " + identity_name
if active_session_count() >= MAX_SESSIONS: if active_session_count() >= MAX_SESSIONS:
return None, "同時セッション数の上限(%d)に達しています" % MAX_SESSIONS return None, "同時セッション数の上限(%d)に達しています" % MAX_SESSIONS
@@ -412,6 +623,9 @@ def reap_once():
live_ids.add(sid) live_ids.add(sid)
reason = None reason = None
if s["phase"] in ("Succeeded", "Failed"): if s["phase"] in ("Succeeded", "Failed"):
# 終了済み Pod は TERMINATED_RETENTION の猶予後に回収(死因調査のため残す)
fin = _rfc3339_epoch(s.get("finishedAt")) or (int(s["createdAt"] or 0) + 120)
if now - fin >= TERMINATED_RETENTION:
reason = "phase=" + s["phase"] reason = "phase=" + s["phase"]
else: else:
try: try:
@@ -486,6 +700,10 @@ td,th{text-align:left;padding:.45rem .3rem;border-bottom:1px solid #8883;vertica
a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none} a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none}
.muted{opacity:.65;font-size:.85rem} .muted{opacity:.65;font-size:.85rem}
code{background:#8882;padding:.1rem .3rem;border-radius:4px} code{background:#8882;padding:.1rem .3rem;border-radius:4px}
.st-Running{color:#059669;font-weight:600}
.st-Failed{color:#dc2626;font-weight:600}
.st-Pending,.st-Succeeded{color:#d97706;font-weight:600}
pre#logbox{white-space:pre-wrap;background:#111;color:#eee;padding:.8rem;border-radius:8px;max-height:50vh;overflow:auto;font-size:.8rem}
</style></head><body> </style></head><body>
<h1>Claude Code — Remote Sessions</h1> <h1>Claude Code — Remote Sessions</h1>
<div id="login" hidden> <div id="login" hidden>
@@ -498,13 +716,51 @@ code{background:#8882;padding:.1rem .3rem;border-radius:4px}
<div id="app" hidden> <div id="app" hidden>
<form onsubmit="return doCreate(event)"> <form onsubmit="return doCreate(event)">
<label for="repo">リポジトリ URL</label> <label for="repo">リポジトリ URL</label>
<input id="repo" placeholder="https://github.com/owner/repo.git または git@host:owner/repo.git" required> <input id="repo" placeholder="https://host/org/repo.git または git@host:org/repo.git(SSH ユーザーは常に git)" required>
<div class="muted">SSH は <code>git@&lt;host&gt;:&lt;org&gt;/&lt;repo&gt;.git</code>(ユーザー名は自分のアカウントではなく必ず <code>git</code>)。認証は下で選ぶ identity の鍵で行われます。</div>
<div class="row"> <div class="row">
<div style="flex:1"><label for="ref">ブランチ / タグ</label><input id="ref" value="main"></div> <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 style="flex:1"><label for="ident">認証 identity</label><select id="ident"></select></div>
</div> </div>
<button type="submit">セッション作成</button> <button type="submit">セッション作成</button>
</form> </form>
<div class="card" id="identcard" hidden>
<div class="row" style="justify-content:space-between">
<strong>認証情報(identity)</strong>
<button class="sec" style="width:auto;margin:0" onclick="loadIdents()">更新</button>
</div>
<table><tbody id="identrows"></tbody></table>
<details style="margin-top:.6rem">
<summary style="cursor:pointer;font-weight:600">+ identity を追加</summary>
<form onsubmit="return addIdent(event)" style="border:0;padding:.4rem 0 0;margin:0">
<div class="row">
<div style="flex:1"><label for="idn">名前</label>
<input id="idn" placeholder="gitea-ssh" pattern="[a-z0-9][a-z0-9-]{0,38}[a-z0-9]" required></div>
<div style="flex:1"><label for="idt">種類</label>
<select id="idt" onchange="identFields()">
<option value="ssh">SSH 秘密鍵</option>
<option value="https-token">HTTPS トークン</option>
</select></div>
</div>
<div id="f-ssh">
<label for="idkey">SSH 秘密鍵(PEM / OpenSSH)</label>
<textarea id="idkey" rows="6" style="width:100%;font-family:monospace;font-size:.8rem"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;..."></textarea>
<label for="idkh">known_hosts(必須)</label>
<textarea id="idkh" rows="2" style="width:100%;font-family:monospace;font-size:.8rem"
placeholder="ssh-keyscan &lt;host&gt; の出力を貼り付け"></textarea>
</div>
<div id="f-tok" hidden>
<label for="idu">ユーザー名</label>
<input id="idu" value="x-access-token">
<label for="idtk">トークン(PAT)</label>
<input id="idtk" type="password" placeholder="Gitea / GitHub の Personal Access Token">
</div>
<button type="submit">保存</button>
<div class="muted">秘密鍵はブラウザ→サーバーへ送信され Secret として保存されます。経路の TLS を有効にしてください。</div>
</form>
</details>
</div>
<div class="card"> <div class="card">
<div class="row" style="justify-content:space-between"> <div class="row" style="justify-content:space-between">
<strong>セッション一覧</strong> <strong>セッション一覧</strong>
@@ -512,24 +768,69 @@ code{background:#8882;padding:.1rem .3rem;border-radius:4px}
</div> </div>
<table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead> <table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead>
<tbody id="rows"></tbody></table> <tbody id="rows"></tbody></table>
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。</p> <p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。終了したセッションは調査用にしばらく残ります。</p>
</div>
<div class="card" id="logcard" hidden>
<div class="row" style="justify-content:space-between">
<strong>ログ: <code id="logid"></code></strong>
<button class="sec" style="width:auto;margin:0" onclick="$('#logcard').hidden=true">閉じる</button>
</div>
<pre id="logbox"></pre>
</div> </div>
</div> </div>
<script> <script>
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
let identities=[]; let identities=[];
function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);} function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);}
let guiIdents=false;
async function boot(){ async function boot(){
const r=await api('/api/config'); const r=await api('/api/config');
if(r.status===401){$('#login').hidden=false;return;} if(r.status===401){$('#login').hidden=false;return;}
const c=await r.json(); const c=await r.json();
identities=c.identities||[]; guiIdents=!!c.guiIdentities;
$('#idle').textContent=c.idleTimeoutSeconds; $('#idle').textContent=c.idleTimeoutSeconds;
const sel=$('#ident');
sel.innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+identities.map(i=>`<option>${i}</option>`).join('');
$('#app').hidden=false; $('#app').hidden=false;
$('#identcard').hidden=!guiIdents;
applyIdents(c.identities||[]);
if(guiIdents) loadIdents();
refresh(); refresh();
} }
function applyIdents(list){
identities=list;
$('#ident').innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+
list.map(i=>`<option>${esc(i.name)}</option>`).join('');
}
function identFields(){
const ssh=$('#idt').value==='ssh';
$('#f-ssh').hidden=!ssh;$('#f-tok').hidden=ssh;
}
async function loadIdents(){
const r=await api('/api/identities');if(!r.ok)return;
const list=(await r.json()).identities||[];
applyIdents(list);
$('#identrows').innerHTML=list.map(i=>`<tr>
<td><code>${esc(i.name)}</code></td><td class="muted">${i.type}</td>
<td class="muted">${i.source}</td>
<td style="text-align:right">${i.source==='gui'
?`<button class="sec" style="width:auto;margin:0" onclick="delIdent('${esc(i.name)}')">削除</button>`
:'<span class="muted">Helm 管理</span>'}</td></tr>`).join('')
||'<tr><td class="muted">未登録</td></tr>';
}
async function addIdent(e){e.preventDefault();
const t=$('#idt').value;
const body={name:$('#idn').value.trim(),type:t};
if(t==='ssh'){body.privateKey=$('#idkey').value;body.knownHosts=$('#idkh').value;}
else{body.username=$('#idu').value.trim();body.token=$('#idtk').value;}
const r=await api('/api/identities',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j=await r.json();
if(!r.ok){alert(j.error||'保存に失敗しました');return false;}
$('#idn').value='';$('#idkey').value='';$('#idkh').value='';$('#idtk').value='';
loadIdents();return false;
}
async function delIdent(name){if(!confirm('identity '+name+' を削除しますか?'))return;
const r=await api('/api/identities/'+encodeURIComponent(name),{method:'DELETE'});
if(!r.ok){const j=await r.json();alert(j.error||'削除に失敗しました');}
loadIdents();}
async function doLogin(e){e.preventDefault(); async function doLogin(e){e.preventDefault();
const r=await api('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:$('#tk').value})}); 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('トークンが違います');} if(r.ok){location.reload();}else{alert('トークンが違います');}
@@ -544,16 +845,34 @@ async function doCreate(e){e.preventDefault();
} }
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return; async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
await api('/api/sessions/'+id,{method:'DELETE'});refresh();} await api('/api/sessions/'+id,{method:'DELETE'});refresh();}
async function showLog(id){
$('#logcard').hidden=false;$('#logid').textContent=id;
$('#logbox').textContent='読み込み中...';
const r=await api('/api/sessions/'+id+'/log');
$('#logbox').textContent=await r.text();
$('#logbox').scrollTop=$('#logbox').scrollHeight;
$('#logcard').scrollIntoView({behavior:'smooth'});
}
function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
async function refresh(){ async function refresh(){
const r=await api('/api/sessions');if(!r.ok)return; const r=await api('/api/sessions');if(!r.ok)return;
const list=(await r.json()).sessions||[]; const list=(await r.json()).sessions||[];
$('#rows').innerHTML=list.map(s=>`<tr> $('#rows').innerHTML=list.map(s=>{
let sub=esc(s.ref);
if(s.phase==='Failed'||s.reason){
const d=[s.reason,(s.exitCode!=null?'exit '+s.exitCode:''),esc(s.message)].filter(Boolean).join(' / ');
if(d) sub+=` — <span class="st-Failed">${d}</span>`;
}
const canOpen=s.phase==='Running';
return `<tr>
<td><code>${s.id}</code></td> <td><code>${s.id}</code></td>
<td>${s.phase}</td> <td><span class="st-${s.phase}">${s.phase}</span></td>
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td> <td style="word-break:break-all">${esc(s.repo)}<div class="muted">${sub}</div></td>
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a> <td>${canOpen?`<a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a> `:''}
<button class="sec" style="width:auto;margin:.3rem .3rem 0 0" onclick="showLog('${s.id}')">ログ</button>
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td> <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>'; </tr>`;
}).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
} }
boot(); boot();
</script></body></html> </script></body></html>
@@ -616,16 +935,28 @@ class Handler(BaseHTTPRequestHandler):
if not token_ok(self): if not token_ok(self):
return self._json(401, {"error": "unauthorized"}) return self._json(401, {"error": "unauthorized"})
return self._json(200, { return self._json(200, {
"identities": sorted(GIT_IDENTITIES.keys()), "identities": list_identities(),
"guiIdentities": GUI_IDENTITIES_ENABLED,
"idleTimeoutSeconds": IDLE_TIMEOUT, "idleTimeoutSeconds": IDLE_TIMEOUT,
"maxLifetimeSeconds": MAX_LIFETIME, "maxLifetimeSeconds": MAX_LIFETIME,
"maxSessions": MAX_SESSIONS, "maxSessions": MAX_SESSIONS,
"authMode": AUTH_MODE, "authMode": AUTH_MODE,
}) })
if path == "/api/identities":
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
return self._json(200, {"identities": list_identities()})
if path == "/api/sessions": if path == "/api/sessions":
if not token_ok(self): if not token_ok(self):
return self._json(401, {"error": "unauthorized"}) return self._json(401, {"error": "unauthorized"})
return self._json(200, {"sessions": list_sessions()}) return self._json(200, {"sessions": list_sessions()})
m = re.match(r"^/api/sessions/([a-f0-9]{10})/log$", path)
if m:
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
status, text = session_log(m.group(1))
return self._send(status if status == 200 else 200,
text or "(ログはまだありません)", "text/plain; charset=utf-8")
return self._send(404, b'{"error":"not found"}') return self._send(404, b'{"error":"not found"}')
def do_POST(self): def do_POST(self):
@@ -648,6 +979,9 @@ class Handler(BaseHTTPRequestHandler):
if err: if err:
return self._json(400, {"error": err}) return self._json(400, {"error": err})
return self._json(201, {"id": sid, "url": "/s/" + sid + "/"}) return self._json(201, {"id": sid, "url": "/s/" + sid + "/"})
if path == "/api/identities":
code, payload = create_gui_identity(self._body())
return self._json(code, payload)
m = re.match(r"^/api/sessions/([a-f0-9]{10})/touch$", path) m = re.match(r"^/api/sessions/([a-f0-9]{10})/touch$", path)
if m: if m:
touch_session(m.group(1), min_interval=0) touch_session(m.group(1), min_interval=0)
@@ -657,11 +991,16 @@ class Handler(BaseHTTPRequestHandler):
def do_DELETE(self): def do_DELETE(self):
if not token_ok(self): if not token_ok(self):
return self._json(401, {"error": "unauthorized"}) return self._json(401, {"error": "unauthorized"})
m = re.match(r"^/api/sessions/([a-f0-9]{10})$", urllib.parse.urlparse(self.path).path) path = urllib.parse.urlparse(self.path).path
if not m: m = re.match(r"^/api/sessions/([a-f0-9]{10})$", path)
return self._send(404, b'{"error":"not found"}') if m:
delete_session(m.group(1)) delete_session(m.group(1))
return self._json(200, {"ok": True}) return self._json(200, {"ok": True})
m = re.match(r"^/api/identities/([a-z0-9][a-z0-9-]{0,39})$", path)
if m:
code, payload = delete_gui_identity(m.group(1))
return self._json(code, payload)
return self._send(404, b'{"error":"not found"}')
def main(): def main():
+38 -2
View File
@@ -86,13 +86,30 @@ esac
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. clone # 2. clone
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# URL のスキームと identity の型が食い違っていないか確認(よくある詰まり)
case "$REPO_URL" in
https://*)
if [ "${GIT_IDENTITY_TYPE:-}" = "ssh" ]; then
err "HTTPS の URL ($REPO_URL) に SSH identity が選ばれています。"
err "→ プライベートリポジトリなら https-token の identity を選ぶか、URL を ssh://git@<host>/<org>/<repo>.git 形式にしてください。"
fi ;;
git@*|ssh://*)
if [ "${GIT_IDENTITY_TYPE:-}" != "ssh" ]; then
err "SSH の URL ($REPO_URL) に SSH identity が選ばれていません(identity=${GIT_IDENTITY_TYPE:-none})。"
err "→ ssh 型の identity(秘密鍵 + known_hosts)を選んでください。"
fi ;;
esac
REPO_DIR="$WORKSPACE_DIR/$(basename "${REPO_URL%.git}")" REPO_DIR="$WORKSPACE_DIR/$(basename "${REPO_URL%.git}")"
if [ -d "$REPO_DIR/.git" ]; then if [ -d "$REPO_DIR/.git" ]; then
log "既存チェックアウトを再利用: $REPO_DIR" log "既存チェックアウトを再利用: $REPO_DIR"
else else
log "clone 中: $REPO_URL ($REPO_REF)" log "clone 中: $REPO_URL ($REPO_REF)"
if ! git clone --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR"; then # 進捗と失敗理由をそのまま Pod ログに出す(git のメッセージが死因の手がかり)
err "clone に失敗しました"; exit 1 if ! GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh} -v" git clone --progress --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR" 2>&1; then
err "clone に失敗しました(上の git / ssh の出力を参照)。"
err "よくある原因: 認証情報の不足・権限なし / known_hosts 不一致 / NetworkPolicy による egress 遮断(接続タイムアウト)。"
exit 1
fi fi
fi fi
cd "$REPO_DIR" cd "$REPO_DIR"
@@ -114,6 +131,25 @@ if ! command -v claude >/dev/null 2>&1; then
fi fi
log "claude: $(claude --version 2>/dev/null || echo 'unknown')" 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 では警告のみ) # 追加 apk(root 権限がある事前ビルドイメージのみ有効。非 root では警告のみ)
if [ -n "${EXTRA_APK_PACKAGES:-}" ]; then if [ -n "${EXTRA_APK_PACKAGES:-}" ]; then
if [ "$(id -u)" = "0" ]; then if [ "$(id -u)" = "0" ]; then
+125
View File
@@ -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
+18 -4
View File
@@ -11,7 +11,15 @@ claude-code ({{ .Chart.Version }}) をデプロイしました。
{{- range .Values.ingress.hosts }} {{- range .Values.ingress.hosts }}
https://{{ .host }}/ https://{{ .host }}/
{{- end }} {{- end }}
{{- else }} {{- end }}
{{- if eq .Values.service.type "LoadBalancer" }}
ロードバランサーの IP を確認してブラウザで開いてください:
kubectl -n {{ .Release.Namespace }} get svc {{ include "claude-code.fullname" . }} -w
→ http://<EXTERNAL-IP>:{{ .Values.service.port }}/
{{- with .Values.service.loadBalancerSourceRanges }}
(接続元は {{ join ", " . }} に制限されています)
{{- end }}
{{- else if not .Values.ingress.enabled }}
kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "claude-code.fullname" . }} 8080:{{ .Values.service.port }} kubectl -n {{ .Release.Namespace }} port-forward svc/{{ include "claude-code.fullname" . }} 8080:{{ .Values.service.port }}
→ http://localhost:8080/ → http://localhost:8080/
{{- end }} {{- end }}
@@ -34,10 +42,16 @@ Basic 認証のユーザーでログインしてください。
⚠ auth.mode=none — 認証なしで公開されています。信頼済みネットワーク限定で使用してください。 ⚠ auth.mode=none — 認証なしで公開されています。信頼済みネットワーク限定で使用してください。
{{- end }} {{- 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 Code の認証情報が未設定です(anthropic.apiKey / oauthToken / credentialsJson / existingSecret のいずれも空)。
セッション内の `claude` は認証に失敗します。`--set anthropic.apiKey=...` で再適用してください。 セッション内の `claude` は認証に失敗します。credentials モードなら:
--set-file anthropic.credentialsJson=$HOME/.claude/.credentials.json
で再適用してください(helm upgrade)。
{{- end }} {{- end }}
{{- if not .Values.git.identities }} {{- if not .Values.git.identities }}
+2
View File
@@ -8,3 +8,5 @@ metadata:
data: data:
manager.py: | manager.py: |
{{ .Files.Get "files/manager.py" | indent 4 }} {{ .Files.Get "files/manager.py" | indent 4 }}
token-refresher.py: |
{{ .Files.Get "files/token-refresher.py" | indent 4 }}
+2
View File
@@ -46,6 +46,8 @@ spec:
value: {{ .Values.session.idleTimeoutSeconds | quote }} value: {{ .Values.session.idleTimeoutSeconds | quote }}
- name: MAX_LIFETIME_SECONDS - name: MAX_LIFETIME_SECONDS
value: {{ .Values.session.maxLifetimeSeconds | quote }} value: {{ .Values.session.maxLifetimeSeconds | quote }}
- name: TERMINATED_RETENTION_SECONDS
value: {{ .Values.session.reaper.terminatedRetentionSeconds | quote }}
- name: TTYD_PORT - name: TTYD_PORT
value: {{ .Values.session.ttydPort | quote }} value: {{ .Values.session.ttydPort | quote }}
securityContext: securityContext:
+10
View File
@@ -131,8 +131,16 @@ spec:
value: {{ include "claude-code.fullname" . }}-session value: {{ include "claude-code.fullname" . }}-session
- name: ANTHROPIC_SECRET - name: ANTHROPIC_SECRET
value: {{ include "claude-code.anthropicSecretName" . | quote }} 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 - name: GIT_IDENTITIES_JSON
value: {{ $idents | toJson | quote }} value: {{ $idents | toJson | quote }}
- name: GUI_IDENTITIES_ENABLED
value: {{ .Values.git.guiIdentities.enabled | quote }}
- name: GIT_FULLNAME_PREFIX
value: {{ include "claude-code.fullname" . | quote }}
- name: GIT_ALLOWED_HOSTS - name: GIT_ALLOWED_HOSTS
value: {{ join "," .Values.git.allowedHosts | quote }} value: {{ join "," .Values.git.allowedHosts | quote }}
- name: GIT_ALLOW_ARBITRARY - name: GIT_ALLOW_ARBITRARY
@@ -147,6 +155,8 @@ spec:
value: {{ .Values.session.maxLifetimeSeconds | quote }} value: {{ .Values.session.maxLifetimeSeconds | quote }}
- name: REAP_INTERVAL_SECONDS - name: REAP_INTERVAL_SECONDS
value: {{ .Values.session.reaper.intervalSeconds | quote }} value: {{ .Values.session.reaper.intervalSeconds | quote }}
- name: TERMINATED_RETENTION_SECONDS
value: {{ .Values.session.reaper.terminatedRetentionSeconds | quote }}
- name: MAX_SESSIONS - name: MAX_SESSIONS
value: {{ .Values.session.maxSessions | quote }} value: {{ .Values.session.maxSessions | quote }}
- name: TTYD_PORT - name: TTYD_PORT
+8
View File
@@ -22,6 +22,14 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["events"] resources: ["events"]
verbs: ["get", "list", "watch"] verbs: ["get", "list", "watch"]
{{- if .Values.git.guiIdentities.enabled }}
# GUI からの git identity 登録(Secret の作成/更新/削除)。
# list は同 NS の他 Secret も対象になりうるが、manager コードは
# ラベル claude-code.cafepieters.com/identity-source=gui のものだけを操作する。
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "update", "patch", "delete"]
{{- end }}
--- ---
apiVersion: rbac.authorization.k8s.io/v1 apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding kind: RoleBinding
+10 -3
View File
@@ -1,6 +1,7 @@
{{- if not .Values.anthropic.existingSecret }} {{- if not .Values.anthropic.existingSecret }}
# Claude Code 認証情報。セッション NS に作成し、セッション Pod へ env として注入する。 # Claude Code 認証情報。セッション NS に作成し、セッション Pod へ注入する。
# 値が空のままインストールするとセッション内の claude が認証に失敗する(NOTES 参照)。 # credentials モードでは refresher CronJob がこの Secret を書き換えるため、
# helm の管理から外れて中身が更新されうる(helm upgrade で上書きしないよう注意)。
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -8,9 +9,15 @@ metadata:
namespace: {{ include "claude-code.sessionsNamespace" . }} namespace: {{ include "claude-code.sessionsNamespace" . }}
labels: labels:
{{- include "claude-code.labels" . | nindent 4 }} {{- include "claude-code.labels" . | nindent 4 }}
annotations:
{{- if eq .Values.anthropic.authMode "credentials" }}
"helm.sh/resource-policy": keep
{{- end }}
type: Opaque type: Opaque
stringData: 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 }} CLAUDE_CODE_OAUTH_TOKEN: {{ .Values.anthropic.oauthToken | quote }}
{{- else }} {{- else }}
ANTHROPIC_API_KEY: {{ .Values.anthropic.apiKey | quote }} ANTHROPIC_API_KEY: {{ .Values.anthropic.apiKey | quote }}
+2
View File
@@ -12,6 +12,8 @@ metadata:
labels: labels:
{{- include "claude-code.labels" $ctx | nindent 4 }} {{- include "claude-code.labels" $ctx | nindent 4 }}
claude-code.cafepieters.com/git-identity: {{ $name | quote }} claude-code.cafepieters.com/git-identity: {{ $name | quote }}
claude-code.cafepieters.com/identity-source: helm
claude-code.cafepieters.com/identity-type: {{ $id.type }}
type: Opaque type: Opaque
stringData: stringData:
{{- if eq $id.type "ssh" }} {{- if eq $id.type "ssh" }}
+23
View File
@@ -6,13 +6,36 @@ metadata:
labels: labels:
{{- include "claude-code.labels" . | nindent 4 }} {{- include "claude-code.labels" . | nindent 4 }}
app.kubernetes.io/component: control-plane app.kubernetes.io/component: control-plane
{{- with .Values.service.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec: spec:
type: {{ .Values.service.type }} type: {{ .Values.service.type }}
{{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerIP }}
loadBalancerIP: {{ .Values.service.loadBalancerIP | quote }}
{{- end }}
{{- with .Values.service.loadBalancerClass }}
loadBalancerClass: {{ . | quote }}
{{- end }}
{{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- toYaml .Values.service.loadBalancerSourceRanges | nindent 4 }}
{{- end }}
{{- with .Values.service.externalTrafficPolicy }}
externalTrafficPolicy: {{ . }}
{{- end }}
ports: ports:
- name: http - name: http
port: {{ .Values.service.port }} port: {{ .Values.service.port }}
targetPort: http targetPort: http
protocol: TCP protocol: TCP
{{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }}
nodePort: {{ .Values.service.nodePort }}
{{- end }}
selector: selector:
{{- include "claude-code.selectorLabels" . | nindent 4 }} {{- include "claude-code.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: control-plane app.kubernetes.io/component: control-plane
+115
View File
@@ -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 }}
+30 -4
View File
@@ -1,5 +1,4 @@
{ {
"$schema": "https://json-schema.org/draft-07/schema#",
"title": "claude-code Helm chart values", "title": "claude-code Helm chart values",
"type": "object", "type": "object",
"additionalProperties": true, "additionalProperties": true,
@@ -109,13 +108,27 @@
"additionalProperties": false, "additionalProperties": false,
"required": ["authMode"], "required": ["authMode"],
"properties": { "properties": {
"authMode": { "type": "string", "enum": ["apiKey", "oauth"] }, "authMode": { "type": "string", "enum": ["apiKey", "oauth", "credentials"] },
"apiKey": { "type": "string" }, "apiKey": { "type": "string" },
"oauthToken": { "type": "string" }, "oauthToken": { "type": "string" },
"credentialsJson": { "type": "string" },
"existingSecret": { "type": "string" }, "existingSecret": { "type": "string" },
"existingSecretKeyApiKey": { "type": "string" }, "existingSecretKeyApiKey": { "type": "string" },
"existingSecretKeyOauthToken": { "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" }
}
}
} }
}, },
@@ -128,6 +141,11 @@
"allowArbitraryRepos": { "type": "boolean" }, "allowArbitraryRepos": { "type": "boolean" },
"defaultAuthorName": { "type": "string" }, "defaultAuthorName": { "type": "string" },
"defaultAuthorEmail": { "type": "string" }, "defaultAuthorEmail": { "type": "string" },
"guiIdentities": {
"type": "object",
"additionalProperties": false,
"properties": { "enabled": { "type": "boolean" } }
},
"identities": { "identities": {
"type": "object", "type": "object",
"additionalProperties": { "additionalProperties": {
@@ -167,6 +185,7 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"intervalSeconds": { "type": "integer", "minimum": 10 }, "intervalSeconds": { "type": "integer", "minimum": 10 },
"terminatedRetentionSeconds": { "type": "integer", "minimum": 0 },
"cronjob": { "cronjob": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@@ -236,7 +255,14 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] }, "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
"port": { "type": "integer", "minimum": 1, "maximum": 65535 } "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"nodePort": { "type": ["integer", "null"], "minimum": 1, "maximum": 65535 },
"annotations": { "type": "object" },
"loadBalancerIP": { "type": "string" },
"loadBalancerClass": { "type": "string" },
"loadBalancerSourceRanges": { "type": "array", "items": { "type": "string" } },
"externalTrafficPolicy": { "type": "string", "enum": ["", "Cluster", "Local"] },
"labels": { "type": "object" }
} }
}, },
+82 -7
View File
@@ -30,7 +30,7 @@ image:
gateway: gateway:
registry: docker.io registry: docker.io
repository: nginxinc/nginx-unprivileged repository: nginxinc/nginx-unprivileged
tag: "1.31.5-alpine" tag: "1.31.6-alpine"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
# 制御プレーン(Python 標準ライブラリのみ・追加 pip 不要) # 制御プレーン(Python 標準ライブラリのみ・追加 pip 不要)
manager: manager:
@@ -50,7 +50,7 @@ image:
session: session:
registry: git.cafepieters.com registry: git.cafepieters.com
repository: helmchart/claude-code/session repository: helmchart/claude-code/session
tag: "2.1.263" tag: "2.1.283"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
imagePullSecrets: [] imagePullSecrets: []
@@ -153,18 +153,52 @@ auth:
# anthropic: Claude Code の認証情報 # anthropic: Claude Code の認証情報
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
anthropic: anthropic:
# apiKey : ANTHROPIC_API_KEY を使う # authMode:
# oauth : CLAUDE_CODE_OAUTH_TOKEN を使う(`claude setup-token` で発行) # credentials : ~/.claude/.credentials.json 全体(refreshToken 込み)を注入(推奨)。
authMode: apiKey # 各セッションはこのファイルを $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: "" apiKey: ""
oauthToken: "" 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 を参照 # 上記の代わりに既存 Secret を参照
existingSecret: "" existingSecret: ""
existingSecretKeyApiKey: "ANTHROPIC_API_KEY" existingSecretKeyApiKey: "ANTHROPIC_API_KEY"
existingSecretKeyOauthToken: "CLAUDE_CODE_OAUTH_TOKEN" existingSecretKeyOauthToken: "CLAUDE_CODE_OAUTH_TOKEN"
existingSecretKeyCredentials: ".credentials.json"
# 全セッションへ渡す追加環境変数(例: ANTHROPIC_BASE_URL / ANTHROPIC_MODEL) # 全セッションへ渡す追加環境変数(例: ANTHROPIC_BASE_URL / ANTHROPIC_MODEL)
extraEnv: {} 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: 接続先ホストと認証アイデンティティ # git: 接続先ホストと認証アイデンティティ
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -182,8 +216,21 @@ git:
defaultAuthorName: "Claude" defaultAuthorName: "Claude"
defaultAuthorEmail: "claude@cafepieters.com" defaultAuthorEmail: "claude@cafepieters.com"
# 認証アイデンティティ。セッション作成時に名前で選択する。 # GUI(Web UI)からの認証情報 identity 登録を許可する。
# 有効にすると、UI の「認証情報」カードから SSH 秘密鍵 + known_hosts / HTTPS トークンを
# 登録・削除できる。登録内容はセッション NS の Secret として保存される。
# トレードオフ:
# - manager にセッション NS の secrets への get/list/create/update/delete 権限が付く
# (同 NS には Claude 認証情報 Secret も存在する点に留意)。
# - 秘密鍵はブラウザ→gateway→manager をリクエストボディで通る。経路の TLS
# (Ingress の tls / LB の TLS 終端)を必ず有効にすること。
# 下の git.identities(Helm 管理)は常に読み取り専用で UI に表示される。
guiIdentities:
enabled: true
# 認証アイデンティティ(Helm 管理)。セッション作成時に名前で選択する。
# GitHub / Gitea / GitLab など SSH または HTTPS トークンで認証する任意のホストに対応。 # GitHub / Gitea / GitLab など SSH または HTTPS トークンで認証する任意のホストに対応。
# GUI から登録したものと合わせて使える(同名は不可)。
identities: {} identities: {}
# gitea-ssh: # gitea-ssh:
# type: ssh # ssh | https-token # type: ssh # ssh | https-token
@@ -234,6 +281,10 @@ session:
reaper: reaper:
# manager 内アイドル回収ループの実行間隔 # manager 内アイドル回収ループの実行間隔
intervalSeconds: 60 intervalSeconds: 60
# 終了済み(Succeeded / Failed)セッション Pod を回収せず残す秒数。
# 死因を UI の「ログ」ボタンや kubectl logs で確認できるよう猶予を持たせる。
# 0 にすると即回収(旧挙動)。
terminatedRetentionSeconds: 900
# 予備の孤児掃除 CronJob(manager 障害時の保険) # 予備の孤児掃除 CronJob(manager 障害時の保険)
cronjob: cronjob:
enabled: true enabled: true
@@ -308,8 +359,32 @@ ingress:
# - claude-code.example.com # - claude-code.example.com
service: service:
type: ClusterIP # LoadBalancer : ローカルネットワークの LB(kube-vip / MetalLB / Cilium LB-IPAM 等)から
# IP を払い出し、その IP で管理画面へアクセスする(Ingress 不要・既定)
# ClusterIP : Service IP のみ。port-forward か Ingress 経由でアクセス
# NodePort : 各ノードの高位ポートで公開
type: LoadBalancer
port: 80 port: 80
# NodePort を固定したい場合のみ(type: NodePort)
nodePort: null
# Service に付与するアノテーション。kube-vip で VIP を指定する例:
# kube-vip.io/loadbalancerIPs: "192.168.9.50"
# kube-vip.io/vipHost: "cpi11"
# MetalLB でプールを指定する例:
# metallb.universe.tf/address-pool: "lan"
annotations: {}
# 払い出す IP を直接指定(LB 実装が対応していれば)。annotations 指定を推奨。
loadBalancerIP: ""
loadBalancerClass: ""
# 接続元をローカルネットワークに限定する(LB 実装が対応していれば)。
# Ingress を張らない構成では、ここで許可レンジを絞ることが第一の防御線になる。
loadBalancerSourceRanges: []
# - 192.168.9.0/24
# - 10.0.0.0/8
# Local にすると実クライアント IP を保持する(kube-proxy/LB 実装依存)
externalTrafficPolicy: ""
# Service 自体への追加ラベル
labels: {}
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 制御プレーンの Pod / コンテナセキュリティ # 制御プレーンの Pod / コンテナセキュリティ