Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6514747b4e | ||
|
|
1b74e230ad |
@@ -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"`。
|
||||||
@@ -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"`。
|
||||||
@@ -57,6 +57,37 @@ Git リポジトリ(GitHub / Gitea など互換 Git 全般)をチェック
|
|||||||
|
|
||||||
## 改修履歴
|
## 改修履歴
|
||||||
|
|
||||||
|
### リポジトリ 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 クラッシュループ修正(location 正規表現のクォート)(2026-09-08, v2.1.263-c)
|
||||||
|
|
||||||
**症状**: gateway コンテナが起動直後に
|
**症状**: gateway コンテナが起動直後に
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ description: >-
|
|||||||
使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・
|
使い捨てコンテナで Git リポジトリ(GitHub / Gitea など)をチェックアウト・編集・
|
||||||
検証・プッシュする。一定時間アイドルなセッションは自動破棄される。
|
検証・プッシュする。一定時間アイドルなセッションは自動破棄される。
|
||||||
type: application
|
type: application
|
||||||
version: 2.1.263-c
|
version: 2.1.263-e
|
||||||
appVersion: "2.1.263"
|
appVersion: "2.1.263"
|
||||||
keywords:
|
keywords:
|
||||||
- claude-code
|
- claude-code
|
||||||
|
|||||||
@@ -409,6 +409,18 @@ Secret は **セッション NS** に作成されます。`credentials` モー
|
|||||||
GitHub でも Gitea でも GitLab でも、SSH か HTTPS トークンで認証できる Git ホストなら同じ仕組みで扱えます。
|
GitHub でも Gitea でも GitLab でも、SSH か HTTPS トークンで認証できる Git ホストなら同じ仕組みで扱えます。
|
||||||
セッション作成時に identity 名を選ぶと、その Secret だけがそのセッション Pod にマウントされます。
|
セッション作成時に identity 名を選ぶと、その Secret だけがそのセッション Pod にマウントされます。
|
||||||
|
|
||||||
|
**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 — 使い捨てセッション
|
||||||
|
|
||||||
| キー | 既定 | 説明 |
|
| キー | 既定 | 説明 |
|
||||||
@@ -489,10 +501,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` の制約。事前ビルドイメージを使う |
|
||||||
@@ -525,6 +540,8 @@ docker buildx build --platform linux/amd64,linux/arm64 \
|
|||||||
|
|
||||||
| バージョン | Claude Code | 変更内容 |
|
| バージョン | Claude Code | 変更内容 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| 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-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-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-a | 2.1.263 | Service を `LoadBalancer` 対応(kube-vip 等でローカル LB の IP から管理画面へアクセス、`loadBalancerSourceRanges` で接続元制限、既定を `LoadBalancer` に変更)。Ingress は従来どおり併用可。API を使わないサブスクリプション認証(`claude setup-token` → `authMode: oauth`)の手順を README に追記 |
|
||||||
|
|||||||
+109
-18
@@ -64,6 +64,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", {})
|
||||||
@@ -102,12 +105,19 @@ ANN_PREFIX = "claude-code.cafepieters.com/"
|
|||||||
SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code"
|
SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code"
|
||||||
|
|
||||||
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
|
# 受け入れる 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}$")
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -146,6 +156,23 @@ 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))))
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# セッション操作
|
# セッション操作
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -166,7 +193,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", ""),
|
||||||
@@ -175,10 +202,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"))
|
||||||
|
|
||||||
@@ -368,12 +418,13 @@ 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)"
|
||||||
|
% (host, ", ".join(GIT_ALLOWED_HOSTS) or "なし"))
|
||||||
if identity_name and identity_name not in GIT_IDENTITIES:
|
if identity_name and identity_name not in GIT_IDENTITIES:
|
||||||
return None, "未知の git identity: " + identity_name
|
return None, "未知の git identity: " + identity_name
|
||||||
if active_session_count() >= MAX_SESSIONS:
|
if active_session_count() >= MAX_SESSIONS:
|
||||||
@@ -434,7 +485,10 @@ 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"):
|
||||||
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:
|
else:
|
||||||
try:
|
try:
|
||||||
created = int(s["createdAt"] or 0)
|
created = int(s["createdAt"] or 0)
|
||||||
@@ -508,6 +562,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>
|
||||||
@@ -520,7 +578,8 @@ 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@<host>:<org>/<repo>.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>
|
||||||
@@ -534,7 +593,14 @@ 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>
|
||||||
@@ -566,16 +632,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=>({'&':'&','<':'<','>':'>'}[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=>{
|
||||||
<td><code>${s.id}</code></td>
|
let sub=esc(s.ref);
|
||||||
<td>${s.phase}</td>
|
if(s.phase==='Failed'||s.reason){
|
||||||
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td>
|
const d=[s.reason,(s.exitCode!=null?'exit '+s.exitCode:''),esc(s.message)].filter(Boolean).join(' / ');
|
||||||
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a>
|
if(d) sub+=` — <span class="st-Failed">${d}</span>`;
|
||||||
<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>';
|
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();
|
boot();
|
||||||
</script></body></html>
|
</script></body></html>
|
||||||
@@ -648,6 +732,13 @@ 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, {"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):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -151,6 +151,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
|
||||||
|
|||||||
@@ -180,6 +180,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,
|
||||||
|
|||||||
@@ -268,6 +268,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
|
||||||
|
|||||||
Reference in New Issue
Block a user