Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
# ============================================================
# Cloudflare(部署目标;操作经本地 wrangler OAuth,无需 API Token)
# ============================================================
# 账户:DJJ(已用 `wrangler whoami` 确认)
# 账户:Lightspeed(已用 `wrangler whoami` 确认)
CLOUDFLARE_ACCOUNT_ID=

# 可选:CI/无头环境用 API Token 代替本地 wrangler OAuth 登录。
Expand All @@ -16,7 +16,7 @@ CLOUDFLARE_ACCOUNT_ID=
# ============================================================
# 部署形态
# ============================================================
# 生产 BaseURL(custom domain;zone pdjjq.orgDJJ 账户下,Watt 已验证可挂)
# 生产 BaseURL(custom domain;zone fantacy.liveLightspeed 账户下)
TB_DOMAIN=
TB_BASE_URL=

Expand Down Expand Up @@ -51,7 +51,7 @@ TB_SK= # 部署后由 tb init 输出的 Admin SK 填入(明文只输
# TB_TEST_MCP_URL=
# TB_TEST_MCP_BEARER=

# Phase 3 / E2E-2 的"外部 S3"兼容端点(用 DJJ 账户 R2 的 S3 API 即可:
# Phase 3 / E2E-2 的"外部 S3"兼容端点(用 Lightspeed 账户 R2 的 S3 API 即可:
# https://<CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com)
# TB_TEST_S3_ENDPOINT=
# TB_TEST_S3_ACCESS_KEY_ID=
Expand Down
97 changes: 97 additions & 0 deletions deploy/k8s/pod-diag.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# tb-pod-diag:集群内只读诊断服务,反向注册到 tool-bridge 树 tools/pod-diag。
#
# 安全模型(结构性只读,不依赖任何可绕过的软约束作为最终边界):
# - 授权面 = Role(限 namespace,非 ClusterRole)只给 pods / pods/log / events 的
# get/list。这些子资源在 K8s API 层【没有写动作】,只读是内核保证,攻破本 pod
# 也偷不到写/exec 能力。
# - 无 shell、无 exec、无 kubectl 二进制;Agent 只能调用六个结构化只读工具。
# - SA token 只在本 pod 内,不经 Agent、不经网关。
#
# 前置:
# 1) 构建推送镜像(见 pod-diag/Dockerfile),替换下方 image;
# 2) 建 device SK Secret(scope 只需 device 注册权):
# kubectl -n tool-bridge create secret generic tb-device-sk \
# --from-literal=sk='<你的 device SK>'
# 3) 把 ALLOWED_NAMESPACES 改成你真正要排查的 namespace(逗号分隔);
# 并为【每一个】这样的 namespace 各建一份下面的 Role + RoleBinding
# (本文件示例只覆盖 default,多 ns 需复制 Role/RoleBinding 段并改 namespace)。
---
apiVersion: v1
kind: Namespace
metadata:
name: tool-bridge
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: pod-diag
namespace: tool-bridge
---
# 只读诊断 Role —— 需在【每个被查询的 namespace】各建一份(此处示例:default)。
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: tb-pod-diag-reader
namespace: default # ← 被查询的目标 namespace
rules:
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["events"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tb-pod-diag-reader
namespace: default # ← 与上面 Role 的 namespace 一致
subjects:
- kind: ServiceAccount
name: pod-diag
namespace: tool-bridge # SA 在 tool-bridge ns,被授权读 default ns
roleRef:
kind: Role
name: tb-pod-diag-reader
apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: tb-pod-diag
namespace: tool-bridge
spec:
replicas: 1 # deviceId 固定,重建后自动重连复用同一树节点
selector:
matchLabels: { app: tb-pod-diag }
template:
metadata:
labels: { app: tb-pod-diag }
spec:
serviceAccountName: pod-diag
automountServiceAccountToken: true # client-node loadFromCluster 需要
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: pod-diag
image: <your-registry>/tb-pod-diag:0.1.0 # ← 改成你推送后的镜像地址
env:
- name: TB_BASE_URL
value: "https://your-tb.example.com" # ← 你的网关地址
- name: TB_SK
valueFrom:
secretKeyRef: { name: tb-device-sk, key: sk }
- name: TB_DEVICE_ID
value: "pod-diag"
- name: ALLOWED_NAMESPACES
value: "default" # ← 逗号分隔;须与上面 Role 覆盖的 ns 一致
- name: MAX_TAIL_LINES
value: "2000"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: "50m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" }
20 changes: 20 additions & 0 deletions deploy/k8s/pod-diag/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# tb-pod-diag 镜像:纯 Node 只读诊断服务(@kubernetes/client-node 直连 in-cluster
# APIServer + @tool-bridge/sdk 反向连接网关)。无 kubectl 二进制、无 shell。
#
# 构建(在 deploy/k8s/pod-diag 目录内):
# docker buildx build --platform linux/amd64 -t <registry>/tb-pod-diag:0.1.0 --push .
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
# 可复现构建:锁定 lockfile 版本;--omit=dev 排除冒烟用的 @hono/node-server。
RUN npm ci --omit=dev
COPY src ./src

FROM node:22-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/src ./src
USER node
CMD ["node", "src/index.mjs"]
95 changes: 95 additions & 0 deletions deploy/k8s/pod-diag/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# tb-pod-diag

集群内**只读**诊断服务:经 `@tool-bridge/sdk` 反向连接到 tool-bridge 网关,把六个结构化只读的 K8s 查询挂到远程树 `device/<deviceId>/tools/pod-diag`。够不到 ACK 集群的 Agent 凭一把 SK + fetch 即可查看 pod 日志、状态、事件。

## 为什么是这个形态(设计决策)

- **不暴露 shell / exec / kubectl**。`kubectl` 二进制放开 = 整个命令面失守;`pods/exec` 在 K8s 层没有"只读"档,进容器即等于可写。二者都被否决。
- **授权面 = namespace-scoped Role**,只给 `pods` / `pods/log` / `events` 的 `get`/`list`。这些子资源在 K8s API 层**没有写动作**,只读是内核保证——即便本 pod 被攻破,SA token 也偷不到写/exec 能力。
- **SA token 只在本 pod 内**,不经 Agent、不经网关。
- 真要 `exec` 进 pod,走人工临时授权,不常设给 Agent。

## 六个只读工具

| 工具 | K8s 调用 | 用途 |
|---|---|---|
| `listPods` | `listNamespacedPod` | 列 pod:阶段/就绪/重启数/节点 |
| `getLogs` | `readNamespacedPodLog` | 拉日志(tailLines/sinceSeconds/container/previous) |
| `describePod` | `readNamespacedPod` | spec + status 详情 |
| `getPodStatus` | `readNamespacedPod` | 精简状态/条件/容器状态 |
| `getEvents` | `listNamespacedEvent` | 事件(调度/OOM/拉镜像失败) |
| `getPodYaml` | `readNamespacedPod` | 完整 manifest(剔 managedFields) |

所有工具的 `namespace` 入参必须落在 `ALLOWED_NAMESPACES` 白名单内。

## 访问方式:纯 HTTP,三入口对等

工具挂上树后就是 HTTP 端点。CLI、Agent、Dashboard 打的是同一个面,没有 CLI 专属通道。
下面 `<gw>` = 网关地址,`<sk>` = **Agent 调用 SK**(见下方"两把 SK")。树路径 `device/pod-diag/tools/pod-diag`(第一段是 deviceId,第二段是注册路径)。

### 发现(GET,自描述)

```sh
# 节点索引:有哪些工具(省略 schema)
curl -H "Authorization: Bearer <sk>" <gw>/device/pod-diag/tools/pod-diag/~help

# 单个工具的完整参数 schema(required + 字段描述都在这一层)
curl -H "Authorization: Bearer <sk>" <gw>/device/pod-diag/tools/pod-diag/getLogs/~help

# 要 JSON(可直接喂 LLM / 渲染表单)
curl -H "Authorization: Bearer <sk>" -H "Accept: application/json" \
<gw>/device/pod-diag/tools/pod-diag/getLogs/~help
```

### 调用(POST,两种等价形态)

形态 A —— 节点路径 + `{tool, arguments}` 信封:

```sh
curl -X POST <gw>/device/pod-diag/tools/pod-diag \
-H "Authorization: Bearer <sk>" -H "Content-Type: application/json" \
-d '{"tool":"getLogs","arguments":{"namespace":"default","pod":"my-pod","tailLines":500,"previous":true}}'
```

形态 B —— 直连工具路径,body 直接就是 arguments:

```sh
curl -X POST <gw>/device/pod-diag/tools/pod-diag/getLogs \
-H "Authorization: Bearer <sk>" -H "Content-Type: application/json" \
-d '{"namespace":"default","pod":"my-pod","tailLines":500}'
```

CLI 等价写法(内部就是发上面的 POST):

```sh
tb call device/pod-diag/tools/pod-diag --tool getLogs \
--args '{"namespace":"default","pod":"my-pod","tailLines":500}'
```

## 两把 SK(别混用)

| 用途 | 谁持有 | scope |
|---|---|---|
| **device SK** | 诊断 pod(`TB_SK` 环境变量,Secret `tb-device-sk`) | 只需 device 注册权 |
| **Agent 调用 SK** | 调用方 Agent | `device/pod-diag/**` 的 `read` + `call` |

权限两级:GET `~help` 要 `read`,POST 调用要 `call`。

## 环境变量

| 变量 | 必填 | 说明 |
|---|---|---|
| `TB_BASE_URL` | ✓ | 网关 base url(https) |
| `TB_SK` | ✓ | device SK |
| `TB_DEVICE_ID` | | 稳定设备 id,缺省 `pod-diag` |
| `ALLOWED_NAMESPACES` | ✓ | 逗号分隔;空 = 拒一切查询 |
| `MAX_TAIL_LINES` | | getLogs 行数上限,缺省 2000 |

## 本地冒烟

`node smoke.mjs` —— 起本地 TB 实例验证反向注册 + HTTP 两种调用形态 + `~help` 两级发现 + SK 鉴权(不连真集群)。构建镜像与部署见上级目录 `../pod-diag.yaml`。

## 已验证 / 未验证

- **已验证**:依赖可解析(`@kubernetes/client-node@1.4.0`、`@tool-bridge/sdk@0.4.0`)、`index.mjs` 语法、K8s client 方法名与 SDK 导出面、`npm ci --omit=dev` 构建、tool-bridge 侧调用链路(冒烟全绿)。
- **未验证**:连真实 in-cluster APIServer 的 K8s 调用(需集群内运行)。首次部署若报错,大概率在 `getLogs`/`listPods` 的返回字段解析处 —— client-node 1.x 返回值直接取(无 `.body`),已按此写。
Loading