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)検証済み
This commit is contained in:
2026-09-08 15:18:21 +09:00
parent cd7092be17
commit 528a48b667
15 changed files with 503 additions and 46 deletions
+23 -1
View File
@@ -54,6 +54,8 @@ SESSION_IMAGE_PULL_POLICY = env("SESSION_IMAGE_PULL_POLICY", "IfNotPresent")
SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", [])
BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session")
ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic")
ANTHROPIC_AUTH_MODE = env("ANTHROPIC_AUTH_MODE", "apiKey")
ANTHROPIC_CREDENTIALS_KEY = env("ANTHROPIC_CREDENTIALS_KEY", ".credentials.json")
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {})
GIT_ALLOWED_HOSTS = [h.strip().lower() for h in env("GIT_ALLOWED_HOSTS", "").split(",") if h.strip()]
GIT_ALLOW_ARBITRARY = env("GIT_ALLOW_ARBITRARY", "false").lower() == "true"
@@ -249,13 +251,33 @@ def build_pod(sid, repo, ref, identity_name, owner):
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")})
# ---- Claude 認証情報の渡し方 ----
# credentials モード: Secret をファイルとしてマウントし、エントリポイントが
# $HOME/.claude/.credentials.json へコピーする(書き込み可能にして claude 自身の
# リフレッシュを許す)。env var 名に "." を含められないため envFrom は使わない。
# apiKey / oauth モード: 有効な env var 名なので envFrom でそのまま注入する。
env_from = []
if ANTHROPIC_AUTH_MODE == "credentials":
volumes.append({
"name": "anthropic-creds",
"secret": {
"secretName": ANTHROPIC_SECRET,
"defaultMode": 0o400,
"items": [{"key": ANTHROPIC_CREDENTIALS_KEY, "path": "credentials.json"}],
},
})
mounts.append({"name": "anthropic-creds", "mountPath": "/anthropic", "readOnly": True})
env_list.append({"name": "SEED_CREDENTIALS_FILE", "value": "/anthropic/credentials.json"})
else:
env_from = [{"secretRef": {"name": ANTHROPIC_SECRET}}]
container = {
"name": "claude",
"image": SESSION_IMAGE,
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
"env": env_list,
"envFrom": [{"secretRef": {"name": ANTHROPIC_SECRET}}],
"envFrom": env_from,
"ports": [{"name": "ttyd", "containerPort": TTYD_PORT}],
"volumeMounts": mounts,
"resources": SESSION_RESOURCES,
+19
View File
@@ -114,6 +114,25 @@ if ! command -v claude >/dev/null 2>&1; then
fi
log "claude: $(claude --version 2>/dev/null || echo 'unknown')"
# --------------------------------------------------------------------------
# 3b. Claude 認証情報(credentials モード)
# --------------------------------------------------------------------------
# Secret からマウントした .credentials.json を書き込み可能な $HOME/.claude へコピーする。
# claude はアクセストークン失効時に refreshToken でこのファイルを自己更新する
# (セッション中のみ有効。クラスタ全体の更新は refresher CronJob が担当)。
if [ -n "${SEED_CREDENTIALS_FILE:-}" ] && [ -r "$SEED_CREDENTIALS_FILE" ]; then
mkdir -p "$HOME/.claude"
chmod 700 "$HOME/.claude"
install -m 600 "$SEED_CREDENTIALS_FILE" "$HOME/.claude/.credentials.json"
if command -v jq >/dev/null 2>&1; then
_exp="$(jq -r '.claudeAiOauth.expiresAt // empty' "$HOME/.claude/.credentials.json" 2>/dev/null)"
[ -n "$_exp" ] && log "Claude 認証情報を配置(accessToken 期限: $(date -u -d "@$(( _exp / 1000 ))" 2>/dev/null || echo "$_exp"))"
else
log "Claude 認証情報を $HOME/.claude/.credentials.json に配置しました"
fi
unset ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN
fi
# 追加 apk(root 権限がある事前ビルドイメージのみ有効。非 root では警告のみ)
if [ -n "${EXTRA_APK_PACKAGES:-}" ]; then
if [ "$(id -u)" = "0" ]; then
+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