feat: 失敗セッションの調査性を改善(終了Pod保持 + ログAPI + UI)(v2.1.263-d)
セッション 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 検証済み
This commit is contained in:
+93
-11
@@ -64,6 +64,9 @@ 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"))
|
||||
# 終了済み(Succeeded / Failed)セッション Pod を残す秒数。0 なら即回収。
|
||||
# 死因を kubectl logs / UI「ログ」で調べられるよう既定で猶予を持たせる。
|
||||
TERMINATED_RETENTION = int(env("TERMINATED_RETENTION_SECONDS", "900"))
|
||||
MAX_SESSIONS = int(env("MAX_SESSIONS", "10"))
|
||||
TTYD_PORT = int(env("TTYD_PORT", "7681"))
|
||||
SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {})
|
||||
@@ -146,6 +149,23 @@ def k8s(method, path, body=None, ctype="application/json"):
|
||||
def core(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))))
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# セッション操作
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -166,7 +186,7 @@ def list_sessions():
|
||||
meta = pod.get("metadata", {})
|
||||
ann = meta.get("annotations", {})
|
||||
st = pod.get("status", {})
|
||||
out.append({
|
||||
info = {
|
||||
"id": meta.get("labels", {}).get(LABEL_SID, ""),
|
||||
"phase": st.get("phase", "Unknown"),
|
||||
"repo": ann.get(ANN_PREFIX + "repo", ""),
|
||||
@@ -175,10 +195,33 @@ def list_sessions():
|
||||
"owner": ann.get(ANN_PREFIX + "owner", ""),
|
||||
"createdAt": ann.get(ANN_PREFIX + "created-at", ""),
|
||||
"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
|
||||
|
||||
|
||||
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():
|
||||
return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running"))
|
||||
|
||||
@@ -434,7 +477,10 @@ def reap_once():
|
||||
live_ids.add(sid)
|
||||
reason = None
|
||||
if s["phase"] in ("Succeeded", "Failed"):
|
||||
reason = "phase=" + s["phase"]
|
||||
# 終了済み 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"]
|
||||
else:
|
||||
try:
|
||||
created = int(s["createdAt"] or 0)
|
||||
@@ -508,6 +554,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}
|
||||
.muted{opacity:.65;font-size:.85rem}
|
||||
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>
|
||||
<h1>Claude Code — Remote Sessions</h1>
|
||||
<div id="login" hidden>
|
||||
@@ -534,7 +584,14 @@ code{background:#8882;padding:.1rem .3rem;border-radius:4px}
|
||||
</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>
|
||||
<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>
|
||||
<script>
|
||||
@@ -566,16 +623,34 @@ async function doCreate(e){e.preventDefault();
|
||||
}
|
||||
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
|
||||
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=>({'&':'&','<':'<','>':'>'}[c]));}
|
||||
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>';
|
||||
$('#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><span class="st-${s.phase}">${s.phase}</span></td>
|
||||
<td style="word-break:break-all">${esc(s.repo)}<div class="muted">${sub}</div></td>
|
||||
<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>
|
||||
</tr>`;
|
||||
}).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
|
||||
}
|
||||
boot();
|
||||
</script></body></html>
|
||||
@@ -648,6 +723,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not token_ok(self):
|
||||
return self._json(401, {"error": "unauthorized"})
|
||||
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"}')
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
@@ -86,13 +86,30 @@ esac
|
||||
# --------------------------------------------------------------------------
|
||||
# 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}")"
|
||||
if [ -d "$REPO_DIR/.git" ]; then
|
||||
log "既存チェックアウトを再利用: $REPO_DIR"
|
||||
else
|
||||
log "clone 中: $REPO_URL ($REPO_REF)"
|
||||
if ! git clone --filter=blob:none --no-tags "$REPO_URL" "$REPO_DIR"; then
|
||||
err "clone に失敗しました"; exit 1
|
||||
# 進捗と失敗理由をそのまま Pod ログに出す(git のメッセージが死因の手がかり)
|
||||
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
|
||||
cd "$REPO_DIR"
|
||||
|
||||
Reference in New Issue
Block a user