feat: Alpine ベースの Claude Code リモート実行 Helm チャート初版 (v2.1.259)
gateway(nginx) + manager(python 標準ライブラリ) + セッションごとの使い捨て Pod + reaper CronJob の構成。ブラウザ(PC/スマホ)からアクセスし、セッション単位で 独立した Alpine コンテナに Git リポジトリ(GitHub/Gitea など)をチェックアウトして 編集・検証・プッシュする。無操作が続いたセッションは Pod ごと自動破棄。 - セキュリティ: セッション NS 分離 / restricted PodSecurity / RBAC はセッション NS の pods,services のみの Role / セッション Pod は SA トークン非マウント・非 root・ readOnlyRootFilesystem・drop ALL caps・seccomp / NetworkPolicy で egress を DNS+HTTPS+SSH に限定 / Git ホスト allow-list / ResourceQuota+LimitRange - ライフサイクル: idleTimeout(Pod 内ウォッチドッグ + manager 回収) / maxLifetime (activeDeadlineSeconds) / maxSessions / 予備の孤児掃除 CronJob - 認証: token(既定) / basic / forward(外部SSO) / none。WebSocket は Cookie トークン - Claude 認証: ANTHROPIC_API_KEY または CLAUDE_CODE_OAUTH_TOKEN - CI: helm-release.yaml(未リリース版のみ tag+release+publish) / image-update-and-release.yaml(週次 npm 追従) / session-image.yaml(multi-arch build) - files/ 実体を .Files.Get で ConfigMap 化。helm lint / template 検証済み
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
claude-code manager — 制御プレーン
|
||||
|
||||
責務:
|
||||
* Web UI(モバイル対応・ビルド不要の素の HTML/JS)
|
||||
* セッションのライフサイクル API(作成 / 一覧 / 破棄 / touch)
|
||||
* gateway(nginx) からの auth_request 検証(token モード)
|
||||
* アイドル / 寿命超過セッションの回収ループ
|
||||
|
||||
依存: Python 標準ライブラリのみ(追加 pip 不要)。
|
||||
Kubernetes API へは in-cluster ServiceAccount トークンで直接アクセスする。
|
||||
セッション NS 内の Pod / Service のみを操作する(RBAC で制限済み)。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 設定(すべて環境変数から)
|
||||
# --------------------------------------------------------------------------
|
||||
def env(key, default=""):
|
||||
return os.environ.get(key, default)
|
||||
|
||||
def env_json(key, default):
|
||||
raw = os.environ.get(key, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
logging.error("invalid JSON in env %s", key)
|
||||
return default
|
||||
|
||||
LISTEN_PORT = int(env("LISTEN_PORT", "8080"))
|
||||
SESSIONS_NS = env("SESSIONS_NAMESPACE", "default")
|
||||
FULLNAME = env("FULLNAME", "claude-code")
|
||||
SESSION_SA = env("SESSION_SERVICE_ACCOUNT", "claude-code-session")
|
||||
SESSION_IMAGE = env("SESSION_IMAGE", "docker.io/library/node:22-alpine")
|
||||
SESSION_IMAGE_PULL_POLICY = env("SESSION_IMAGE_PULL_POLICY", "IfNotPresent")
|
||||
SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", [])
|
||||
BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session")
|
||||
ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic")
|
||||
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {})
|
||||
GIT_ALLOWED_HOSTS = [h.strip().lower() for h in env("GIT_ALLOWED_HOSTS", "").split(",") if h.strip()]
|
||||
GIT_ALLOW_ARBITRARY = env("GIT_ALLOW_ARBITRARY", "false").lower() == "true"
|
||||
GIT_AUTHOR_NAME = env("GIT_DEFAULT_AUTHOR_NAME", "Claude")
|
||||
GIT_AUTHOR_EMAIL = env("GIT_DEFAULT_AUTHOR_EMAIL", "claude@cafepieters.com")
|
||||
IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800"))
|
||||
MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200"))
|
||||
REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60"))
|
||||
MAX_SESSIONS = int(env("MAX_SESSIONS", "10"))
|
||||
TTYD_PORT = int(env("TTYD_PORT", "7681"))
|
||||
SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {})
|
||||
WORKSPACE_DIR = env("WORKSPACE_DIR", "/workspace")
|
||||
AUTOSTART_CLAUDE = env("AUTOSTART_CLAUDE", "true")
|
||||
VERIFY_COMMAND = env("VERIFY_COMMAND", "")
|
||||
AUTOPUSH = env_json("AUTOPUSH_JSON", {"enabled": False})
|
||||
EXTRA_APK = env("EXTRA_APK_PACKAGES", "")
|
||||
CLAUDE_CODE_VERSION = env("CLAUDE_CODE_VERSION", "")
|
||||
ANTHROPIC_EXTRA_ENV = env_json("ANTHROPIC_EXTRA_ENV_JSON", {})
|
||||
POD_LABELS = env_json("SESSION_POD_LABELS_JSON", {})
|
||||
POD_ANNOTATIONS = env_json("SESSION_POD_ANNOTATIONS_JSON", {})
|
||||
NODE_SELECTOR = env_json("SESSION_NODE_SELECTOR_JSON", {})
|
||||
TOLERATIONS = env_json("SESSION_TOLERATIONS_JSON", [])
|
||||
PRIORITY_CLASS = env("SESSION_PRIORITY_CLASS", "")
|
||||
SESSION_RUN_UID = int(env("SESSION_RUN_UID", "10001"))
|
||||
|
||||
AUTH_MODE = env("AUTH_MODE", "token")
|
||||
_auth_token_file = env("AUTH_TOKEN_FILE", "")
|
||||
if _auth_token_file and os.path.exists(_auth_token_file):
|
||||
with open(_auth_token_file, "r", encoding="utf-8") as fh:
|
||||
AUTH_TOKEN = fh.read().strip()
|
||||
else:
|
||||
AUTH_TOKEN = env("AUTH_TOKEN", "")
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, env("LOG_LEVEL", "info").upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("manager")
|
||||
|
||||
LABEL_APP = "app.kubernetes.io/name"
|
||||
LABEL_COMPONENT = "app.kubernetes.io/component"
|
||||
LABEL_SID = "claude-code.cafepieters.com/session-id"
|
||||
ANN_PREFIX = "claude-code.cafepieters.com/"
|
||||
SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code"
|
||||
|
||||
SID_RE = re.compile(r"^[a-f0-9]{10}$")
|
||||
# 受け入れる repo URL: https://host/... または ssh://git@host/... または git@host:path
|
||||
REPO_RE = re.compile(
|
||||
r"^(?:https://(?P<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
|
||||
r"|ssh://git@(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
|
||||
r"|git@(?P<h3>[A-Za-z0-9.\-]+):[\w./\-~%]+)$"
|
||||
)
|
||||
REF_RE = re.compile(r"^[\w./\-]{1,200}$")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Kubernetes API クライアント(標準ライブラリのみ)
|
||||
# --------------------------------------------------------------------------
|
||||
K8S_HOST = "https://kubernetes.default.svc"
|
||||
_sa_dir = "/var/run/secrets/kubernetes.io/serviceaccount"
|
||||
with open(_sa_dir + "/token", "r", encoding="utf-8") as fh:
|
||||
K8S_TOKEN = fh.read().strip()
|
||||
_ssl_ctx = ssl.create_default_context(cafile=_sa_dir + "/ca.crt")
|
||||
|
||||
|
||||
def k8s(method, path, body=None, ctype="application/json"):
|
||||
url = K8S_HOST + path
|
||||
data = None
|
||||
if body is not None:
|
||||
data = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(url=url, method=method, data=data)
|
||||
req.add_header("Authorization", "Bearer " + K8S_TOKEN)
|
||||
req.add_header("Accept", "application/json")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", ctype)
|
||||
try:
|
||||
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
return resp.status, (json.loads(raw) if raw else {})
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
payload = {"message": raw.decode("utf-8", "replace")}
|
||||
return exc.code, payload
|
||||
|
||||
|
||||
def core(path):
|
||||
return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# セッション操作
|
||||
# --------------------------------------------------------------------------
|
||||
def repo_host(url):
|
||||
m = REPO_RE.match(url)
|
||||
if not m:
|
||||
return None
|
||||
return (m.group("h1") or m.group("h2") or m.group("h3") or "").lower()
|
||||
|
||||
|
||||
def list_sessions():
|
||||
status, payload = k8s("GET", core("/pods?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR)))
|
||||
if status != 200:
|
||||
log.error("list pods failed: %s %s", status, payload)
|
||||
return []
|
||||
out = []
|
||||
for pod in payload.get("items", []):
|
||||
meta = pod.get("metadata", {})
|
||||
ann = meta.get("annotations", {})
|
||||
st = pod.get("status", {})
|
||||
out.append({
|
||||
"id": meta.get("labels", {}).get(LABEL_SID, ""),
|
||||
"phase": st.get("phase", "Unknown"),
|
||||
"repo": ann.get(ANN_PREFIX + "repo", ""),
|
||||
"ref": ann.get(ANN_PREFIX + "ref", ""),
|
||||
"identity": ann.get(ANN_PREFIX + "git-identity", ""),
|
||||
"owner": ann.get(ANN_PREFIX + "owner", ""),
|
||||
"createdAt": ann.get(ANN_PREFIX + "created-at", ""),
|
||||
"lastActivity": ann.get(ANN_PREFIX + "last-activity", ""),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def active_session_count():
|
||||
return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running"))
|
||||
|
||||
|
||||
def build_pod(sid, repo, ref, identity_name, owner):
|
||||
now = str(int(time.time()))
|
||||
env_list = [
|
||||
{"name": "SESSION_ID", "value": sid},
|
||||
{"name": "REPO_URL", "value": repo},
|
||||
{"name": "REPO_REF", "value": ref},
|
||||
{"name": "HOME", "value": "/home/claude"},
|
||||
{"name": "WORKSPACE_DIR", "value": WORKSPACE_DIR},
|
||||
{"name": "TTYD_PORT", "value": str(TTYD_PORT)},
|
||||
{"name": "IDLE_TIMEOUT_SECONDS", "value": str(IDLE_TIMEOUT)},
|
||||
{"name": "AUTOSTART_CLAUDE", "value": AUTOSTART_CLAUDE},
|
||||
{"name": "VERIFY_COMMAND", "value": VERIFY_COMMAND},
|
||||
{"name": "AUTOPUSH_ENABLED", "value": str(bool(AUTOPUSH.get("enabled"))).lower()},
|
||||
{"name": "AUTOPUSH_BRANCH_PREFIX", "value": AUTOPUSH.get("branchPrefix", "claude/")},
|
||||
{"name": "AUTOPUSH_PROTECTED", "value": ",".join(AUTOPUSH.get("protectedBranches", []))},
|
||||
{"name": "EXTRA_APK_PACKAGES", "value": EXTRA_APK},
|
||||
{"name": "CLAUDE_CODE_VERSION", "value": CLAUDE_CODE_VERSION},
|
||||
{"name": "GIT_AUTHOR_NAME", "value": GIT_AUTHOR_NAME},
|
||||
{"name": "GIT_AUTHOR_EMAIL", "value": GIT_AUTHOR_EMAIL},
|
||||
{"name": "GIT_COMMITTER_NAME", "value": GIT_AUTHOR_NAME},
|
||||
{"name": "GIT_COMMITTER_EMAIL", "value": GIT_AUTHOR_EMAIL},
|
||||
]
|
||||
for key, val in ANTHROPIC_EXTRA_ENV.items():
|
||||
env_list.append({"name": str(key), "value": str(val)})
|
||||
|
||||
volumes = [
|
||||
{"name": "workspace", "emptyDir": {"sizeLimit": "4Gi"}},
|
||||
{"name": "home", "emptyDir": {"sizeLimit": "1Gi"}},
|
||||
{"name": "tmp", "emptyDir": {"medium": "Memory", "sizeLimit": "64Mi"}},
|
||||
{"name": "npm-cache", "emptyDir": {"sizeLimit": "1Gi"}},
|
||||
{"name": "bootstrap", "configMap": {"name": BOOTSTRAP_CM, "defaultMode": 0o555}},
|
||||
]
|
||||
mounts = [
|
||||
{"name": "workspace", "mountPath": WORKSPACE_DIR},
|
||||
{"name": "home", "mountPath": "/home/claude"},
|
||||
{"name": "tmp", "mountPath": "/tmp"},
|
||||
{"name": "npm-cache", "mountPath": "/home/claude/.npm"},
|
||||
{"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True},
|
||||
]
|
||||
|
||||
ident = GIT_IDENTITIES.get(identity_name)
|
||||
if ident:
|
||||
env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")})
|
||||
if ident.get("type") == "ssh":
|
||||
volumes.append({
|
||||
"name": "git-identity",
|
||||
"secret": {
|
||||
"secretName": ident["secret"],
|
||||
"defaultMode": 0o400,
|
||||
"items": [
|
||||
{"key": ident.get("keyPrivateKey", "id"), "path": "id"},
|
||||
{"key": ident.get("keyKnownHosts", "known_hosts"), "path": "known_hosts"},
|
||||
],
|
||||
},
|
||||
})
|
||||
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
|
||||
elif ident.get("type") == "https-token":
|
||||
volumes.append({
|
||||
"name": "git-identity",
|
||||
"secret": {
|
||||
"secretName": ident["secret"],
|
||||
"defaultMode": 0o400,
|
||||
"items": [{"key": ident.get("keyToken", "token"), "path": "token"}],
|
||||
},
|
||||
})
|
||||
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
|
||||
env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")})
|
||||
|
||||
container = {
|
||||
"name": "claude",
|
||||
"image": SESSION_IMAGE,
|
||||
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
|
||||
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
|
||||
"env": env_list,
|
||||
"envFrom": [{"secretRef": {"name": ANTHROPIC_SECRET}}],
|
||||
"ports": [{"name": "ttyd", "containerPort": TTYD_PORT}],
|
||||
"volumeMounts": mounts,
|
||||
"resources": SESSION_RESOURCES,
|
||||
"securityContext": {
|
||||
"allowPrivilegeEscalation": False,
|
||||
"readOnlyRootFilesystem": True,
|
||||
"runAsNonRoot": True,
|
||||
"runAsUser": SESSION_RUN_UID,
|
||||
"capabilities": {"drop": ["ALL"]},
|
||||
},
|
||||
"readinessProbe": {
|
||||
"tcpSocket": {"port": TTYD_PORT},
|
||||
"initialDelaySeconds": 5,
|
||||
"periodSeconds": 5,
|
||||
"failureThreshold": 60,
|
||||
},
|
||||
}
|
||||
|
||||
labels = {
|
||||
LABEL_APP: "claude-code",
|
||||
LABEL_COMPONENT: "session",
|
||||
"app.kubernetes.io/managed-by": "claude-code-manager",
|
||||
LABEL_SID: sid,
|
||||
}
|
||||
labels.update({str(k): str(v) for k, v in POD_LABELS.items()})
|
||||
annotations = {
|
||||
ANN_PREFIX + "repo": repo,
|
||||
ANN_PREFIX + "ref": ref,
|
||||
ANN_PREFIX + "git-identity": identity_name or "",
|
||||
ANN_PREFIX + "owner": owner or "anon",
|
||||
ANN_PREFIX + "created-at": now,
|
||||
ANN_PREFIX + "last-activity": now,
|
||||
}
|
||||
annotations.update({str(k): str(v) for k, v in POD_ANNOTATIONS.items()})
|
||||
|
||||
spec = {
|
||||
"serviceAccountName": SESSION_SA,
|
||||
"automountServiceAccountToken": False,
|
||||
"restartPolicy": "Never",
|
||||
"activeDeadlineSeconds": MAX_LIFETIME,
|
||||
"terminationGracePeriodSeconds": 30,
|
||||
"enableServiceLinks": False,
|
||||
"securityContext": {
|
||||
"runAsNonRoot": True,
|
||||
"runAsUser": SESSION_RUN_UID,
|
||||
"runAsGroup": SESSION_RUN_UID,
|
||||
"fsGroup": SESSION_RUN_UID,
|
||||
"seccompProfile": {"type": "RuntimeDefault"},
|
||||
},
|
||||
"containers": [container],
|
||||
"volumes": volumes,
|
||||
}
|
||||
if SESSION_IMAGE_PULL_SECRETS:
|
||||
spec["imagePullSecrets"] = SESSION_IMAGE_PULL_SECRETS
|
||||
if NODE_SELECTOR:
|
||||
spec["nodeSelector"] = NODE_SELECTOR
|
||||
if TOLERATIONS:
|
||||
spec["tolerations"] = TOLERATIONS
|
||||
if PRIORITY_CLASS:
|
||||
spec["priorityClassName"] = PRIORITY_CLASS
|
||||
|
||||
return {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Pod",
|
||||
"metadata": {"name": "claude-sess-" + sid, "namespace": SESSIONS_NS,
|
||||
"labels": labels, "annotations": annotations},
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
|
||||
def build_service(sid):
|
||||
return {
|
||||
"apiVersion": "v1",
|
||||
"kind": "Service",
|
||||
"metadata": {
|
||||
"name": "claude-sess-" + sid,
|
||||
"namespace": SESSIONS_NS,
|
||||
"labels": {LABEL_APP: "claude-code", LABEL_COMPONENT: "session", LABEL_SID: sid},
|
||||
},
|
||||
"spec": {
|
||||
"selector": {LABEL_SID: sid},
|
||||
"ports": [{"name": "ttyd", "port": TTYD_PORT, "targetPort": TTYD_PORT}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_session(repo, ref, identity_name, owner):
|
||||
repo = (repo or "").strip()
|
||||
ref = (ref or "main").strip()
|
||||
if not REPO_RE.match(repo):
|
||||
return None, "リポジトリ URL の形式が不正です"
|
||||
if not REF_RE.match(ref):
|
||||
return None, "ref の形式が不正です"
|
||||
host = repo_host(repo)
|
||||
if not GIT_ALLOW_ARBITRARY and host not in GIT_ALLOWED_HOSTS:
|
||||
return None, "許可されていない Git ホストです: " + str(host)
|
||||
if identity_name and identity_name not in GIT_IDENTITIES:
|
||||
return None, "未知の git identity: " + identity_name
|
||||
if active_session_count() >= MAX_SESSIONS:
|
||||
return None, "同時セッション数の上限(%d)に達しています" % MAX_SESSIONS
|
||||
|
||||
sid = secrets.token_hex(5)
|
||||
status, payload = k8s("POST", core("/pods"), build_pod(sid, repo, ref, identity_name, owner))
|
||||
if status not in (200, 201):
|
||||
return None, "Pod 作成に失敗: %s" % payload.get("message", status)
|
||||
status, payload = k8s("POST", core("/services"), build_service(sid))
|
||||
if status not in (200, 201):
|
||||
k8s("DELETE", core("/pods/claude-sess-" + sid))
|
||||
return None, "Service 作成に失敗: %s" % payload.get("message", status)
|
||||
log.info("session %s created repo=%s ref=%s owner=%s", sid, repo, ref, owner)
|
||||
return sid, None
|
||||
|
||||
|
||||
def delete_session(sid):
|
||||
if not SID_RE.match(sid):
|
||||
return False
|
||||
k8s("DELETE", core("/pods/claude-sess-" + sid), {"gracePeriodSeconds": 10})
|
||||
k8s("DELETE", core("/services/claude-sess-" + sid))
|
||||
log.info("session %s deleted", sid)
|
||||
return True
|
||||
|
||||
|
||||
_touch_cache = {}
|
||||
|
||||
|
||||
def touch_session(sid, min_interval=15):
|
||||
if not SID_RE.match(sid):
|
||||
return
|
||||
now = time.time()
|
||||
if now - _touch_cache.get(sid, 0) < min_interval:
|
||||
return
|
||||
_touch_cache[sid] = now
|
||||
patch = {"metadata": {"annotations": {ANN_PREFIX + "last-activity": str(int(now))}}}
|
||||
k8s("PATCH", core("/pods/claude-sess-" + sid), patch,
|
||||
ctype="application/merge-patch+json")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 回収ループ
|
||||
# --------------------------------------------------------------------------
|
||||
def reaper_loop():
|
||||
while True:
|
||||
time.sleep(REAP_INTERVAL)
|
||||
try:
|
||||
reap_once()
|
||||
except Exception as exc: # noqa: BLE001 - ループを止めない
|
||||
log.exception("reaper error: %s", exc)
|
||||
|
||||
|
||||
def reap_once():
|
||||
now = int(time.time())
|
||||
live_ids = set()
|
||||
for s in list_sessions():
|
||||
sid = s["id"]
|
||||
live_ids.add(sid)
|
||||
reason = None
|
||||
if s["phase"] in ("Succeeded", "Failed"):
|
||||
reason = "phase=" + s["phase"]
|
||||
else:
|
||||
try:
|
||||
created = int(s["createdAt"] or 0)
|
||||
last = int(s["lastActivity"] or 0)
|
||||
except ValueError:
|
||||
created = last = now
|
||||
if created and now - created > MAX_LIFETIME:
|
||||
reason = "max-lifetime"
|
||||
elif last and now - last > IDLE_TIMEOUT:
|
||||
reason = "idle"
|
||||
if reason:
|
||||
log.info("reaping session %s (%s)", sid, reason)
|
||||
delete_session(sid)
|
||||
live_ids.discard(sid)
|
||||
# 孤児 Service の掃除
|
||||
status, payload = k8s("GET", core("/services?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR)))
|
||||
if status == 200:
|
||||
for svc in payload.get("items", []):
|
||||
sid = svc.get("metadata", {}).get("labels", {}).get(LABEL_SID, "")
|
||||
if sid and sid not in live_ids:
|
||||
log.info("reaping orphan service %s", sid)
|
||||
k8s("DELETE", core("/services/claude-sess-" + sid))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 認証(token モード)
|
||||
# --------------------------------------------------------------------------
|
||||
def extract_token(handler):
|
||||
auth = handler.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
cookie = handler.headers.get("Cookie", "")
|
||||
for part in cookie.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("cc_token="):
|
||||
return urllib.parse.unquote(part[9:])
|
||||
qs = urllib.parse.urlparse(handler.path).query
|
||||
params = urllib.parse.parse_qs(qs)
|
||||
if "token" in params:
|
||||
return params["token"][0]
|
||||
return ""
|
||||
|
||||
|
||||
def token_ok(handler):
|
||||
if AUTH_MODE != "token":
|
||||
return True
|
||||
if not AUTH_TOKEN:
|
||||
return False
|
||||
return hmac.compare_digest(extract_token(handler), AUTH_TOKEN)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTTP ハンドラ
|
||||
# --------------------------------------------------------------------------
|
||||
INDEX_HTML = """<!doctype html>
|
||||
<html lang="ja"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Claude Code — Remote Sessions</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark}
|
||||
*{box-sizing:border-box}
|
||||
body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;margin:0;padding:1rem;max-width:760px;margin-inline:auto}
|
||||
h1{font-size:1.15rem;margin:.2rem 0 1rem}
|
||||
form,.card{border:1px solid #8884;border-radius:10px;padding:1rem;margin-bottom:1rem}
|
||||
label{display:block;font-weight:600;margin:.5rem 0 .2rem}
|
||||
input,select,button{font:inherit;padding:.55rem .6rem;border-radius:8px;border:1px solid #8886;width:100%}
|
||||
button{background:#4338ca;color:#fff;border:0;font-weight:600;cursor:pointer;margin-top:.8rem}
|
||||
button.sec{background:#8883;color:inherit}
|
||||
.row{display:flex;gap:.5rem;align-items:center}
|
||||
.row>*{width:auto}
|
||||
table{width:100%;border-collapse:collapse;font-size:.92rem}
|
||||
td,th{text-align:left;padding:.45rem .3rem;border-bottom:1px solid #8883;vertical-align:top}
|
||||
a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none}
|
||||
.muted{opacity:.65;font-size:.85rem}
|
||||
code{background:#8882;padding:.1rem .3rem;border-radius:4px}
|
||||
</style></head><body>
|
||||
<h1>Claude Code — Remote Sessions</h1>
|
||||
<div id="login" hidden>
|
||||
<form onsubmit="return doLogin(event)">
|
||||
<label for="tk">アクセストークン</label>
|
||||
<input id="tk" type="password" autocomplete="current-password" placeholder="Bearer token">
|
||||
<button type="submit">ログイン</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="app" hidden>
|
||||
<form onsubmit="return doCreate(event)">
|
||||
<label for="repo">リポジトリ URL</label>
|
||||
<input id="repo" placeholder="https://github.com/owner/repo.git または git@host:owner/repo.git" required>
|
||||
<div class="row">
|
||||
<div style="flex:1"><label for="ref">ブランチ / タグ</label><input id="ref" value="main"></div>
|
||||
<div style="flex:1"><label for="ident">認証 identity</label><select id="ident"></select></div>
|
||||
</div>
|
||||
<button type="submit">セッション作成</button>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<strong>セッション一覧</strong>
|
||||
<button class="sec" style="width:auto;margin:0" onclick="refresh()">更新</button>
|
||||
</div>
|
||||
<table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead>
|
||||
<tbody id="rows"></tbody></table>
|
||||
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
let identities=[];
|
||||
function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);}
|
||||
async function boot(){
|
||||
const r=await api('/api/config');
|
||||
if(r.status===401){$('#login').hidden=false;return;}
|
||||
const c=await r.json();
|
||||
identities=c.identities||[];
|
||||
$('#idle').textContent=c.idleTimeoutSeconds;
|
||||
const sel=$('#ident');
|
||||
sel.innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+identities.map(i=>`<option>${i}</option>`).join('');
|
||||
$('#app').hidden=false;
|
||||
refresh();
|
||||
}
|
||||
async function doLogin(e){e.preventDefault();
|
||||
const r=await api('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:$('#tk').value})});
|
||||
if(r.ok){location.reload();}else{alert('トークンが違います');}
|
||||
return false;
|
||||
}
|
||||
async function doCreate(e){e.preventDefault();
|
||||
const body={repo:$('#repo').value.trim(),ref:$('#ref').value.trim()||'main',identity:$('#ident').value};
|
||||
const r=await api('/api/sessions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
const j=await r.json();
|
||||
if(!r.ok){alert(j.error||'作成に失敗しました');return false;}
|
||||
$('#repo').value='';refresh();return false;
|
||||
}
|
||||
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
|
||||
await api('/api/sessions/'+id,{method:'DELETE'});refresh();}
|
||||
async function refresh(){
|
||||
const r=await api('/api/sessions');if(!r.ok)return;
|
||||
const list=(await r.json()).sessions||[];
|
||||
$('#rows').innerHTML=list.map(s=>`<tr>
|
||||
<td><code>${s.id}</code></td>
|
||||
<td>${s.phase}</td>
|
||||
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td>
|
||||
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a>
|
||||
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td>
|
||||
</tr>`).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
|
||||
}
|
||||
boot();
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "claude-code-manager"
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def _send(self, code, body=b"", ctype="application/json; charset=utf-8", extra=None):
|
||||
if isinstance(body, str):
|
||||
body = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
for key, val in (extra or {}).items():
|
||||
self.send_header(key, val)
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, code, obj, extra=None):
|
||||
self._send(code, json.dumps(obj), "application/json; charset=utf-8", extra)
|
||||
|
||||
def _body(self):
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length <= 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(self.rfile.read(length) or b"{}")
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
log.debug("%s - %s", self.address_string(), fmt % args)
|
||||
|
||||
# ---- ルーティング ----
|
||||
def do_GET(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == "/healthz":
|
||||
return self._send(200, b'{"ok":true}')
|
||||
if path == "/auth":
|
||||
# nginx auth_request 用(token モードのみ意味を持つ)
|
||||
if token_ok(self):
|
||||
orig = self.headers.get("X-Original-URI", "")
|
||||
m = re.search(r"/s/([a-f0-9]{10})/", orig)
|
||||
if m:
|
||||
try:
|
||||
touch_session(m.group(1))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return self._send(200, b"ok", "text/plain")
|
||||
return self._send(401, b"unauthorized", "text/plain")
|
||||
if path == "/" or path == "/index.html":
|
||||
return self._send(200, INDEX_HTML, "text/html; charset=utf-8")
|
||||
if path == "/api/config":
|
||||
if not token_ok(self):
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
return self._json(200, {
|
||||
"identities": sorted(GIT_IDENTITIES.keys()),
|
||||
"idleTimeoutSeconds": IDLE_TIMEOUT,
|
||||
"maxLifetimeSeconds": MAX_LIFETIME,
|
||||
"maxSessions": MAX_SESSIONS,
|
||||
"authMode": AUTH_MODE,
|
||||
})
|
||||
if path == "/api/sessions":
|
||||
if not token_ok(self):
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
return self._json(200, {"sessions": list_sessions()})
|
||||
return self._send(404, b'{"error":"not found"}')
|
||||
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path == "/login":
|
||||
body = self._body()
|
||||
token = str(body.get("token", ""))
|
||||
if AUTH_MODE == "token" and AUTH_TOKEN and hmac.compare_digest(token, AUTH_TOKEN):
|
||||
cookie = ("cc_token=" + urllib.parse.quote(token) +
|
||||
"; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000")
|
||||
return self._json(200, {"ok": True}, {"Set-Cookie": cookie})
|
||||
return self._json(401, {"error": "invalid token"})
|
||||
if not token_ok(self):
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
if path == "/api/sessions":
|
||||
body = self._body()
|
||||
owner = self.headers.get("X-Forwarded-User", "") or "anon"
|
||||
sid, err = create_session(body.get("repo", ""), body.get("ref", "main"),
|
||||
body.get("identity", ""), owner)
|
||||
if err:
|
||||
return self._json(400, {"error": err})
|
||||
return self._json(201, {"id": sid, "url": "/s/" + sid + "/"})
|
||||
m = re.match(r"^/api/sessions/([a-f0-9]{10})/touch$", path)
|
||||
if m:
|
||||
touch_session(m.group(1), min_interval=0)
|
||||
return self._json(200, {"ok": True})
|
||||
return self._send(404, b'{"error":"not found"}')
|
||||
|
||||
def do_DELETE(self):
|
||||
if not token_ok(self):
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
m = re.match(r"^/api/sessions/([a-f0-9]{10})$", urllib.parse.urlparse(self.path).path)
|
||||
if not m:
|
||||
return self._send(404, b'{"error":"not found"}')
|
||||
delete_session(m.group(1))
|
||||
return self._json(200, {"ok": True})
|
||||
|
||||
|
||||
def main():
|
||||
log.info("claude-code manager starting: ns=%s image=%s idle=%ds maxLife=%ds maxSessions=%d authMode=%s",
|
||||
SESSIONS_NS, SESSION_IMAGE, IDLE_TIMEOUT, MAX_LIFETIME, MAX_SESSIONS, AUTH_MODE)
|
||||
if AUTH_MODE == "token" and not AUTH_TOKEN:
|
||||
log.warning("AUTH_MODE=token だがトークンが空。全リクエストを拒否します。")
|
||||
threading.Thread(target=reaper_loop, name="reaper", daemon=True).start()
|
||||
httpd = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
|
||||
httpd.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if "--reap-once" in sys.argv[1:]:
|
||||
# 予備の CronJob から呼ばれる一回きりの回収。manager 障害時の保険。
|
||||
log.info("one-shot reap starting (ns=%s)", SESSIONS_NS)
|
||||
reap_once()
|
||||
log.info("one-shot reap done")
|
||||
else:
|
||||
main()
|
||||
Reference in New Issue
Block a user