feat: add provider profile removal
This commit is contained in:
@@ -53,6 +53,7 @@ POST /api/v1/sessions/:sessionId/control
|
|||||||
GET /api/v1/backends
|
GET /api/v1/backends
|
||||||
GET /api/v1/provider-profiles
|
GET /api/v1/provider-profiles
|
||||||
GET /api/v1/provider-profiles/:profile
|
GET /api/v1/provider-profiles/:profile
|
||||||
|
DELETE /api/v1/provider-profiles/:profile
|
||||||
PUT /api/v1/provider-profiles/:profile/credential
|
PUT /api/v1/provider-profiles/:profile/credential
|
||||||
POST /api/v1/provider-profiles/:profile/validate
|
POST /api/v1/provider-profiles/:profile/validate
|
||||||
GET /api/v1/provider-profiles/:profile/validations/:validationId
|
GET /api/v1/provider-profiles/:profile/validations/:validationId
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
|
|||||||
./scripts/agentrun secrets codex render --dry-run [--profile codex|deepseek|minimax-m3] [--codex-home <dir>]
|
./scripts/agentrun secrets codex render --dry-run [--profile codex|deepseek|minimax-m3] [--codex-home <dir>]
|
||||||
./scripts/agentrun provider-profiles list
|
./scripts/agentrun provider-profiles list
|
||||||
./scripts/agentrun provider-profiles show <profile>
|
./scripts/agentrun provider-profiles show <profile>
|
||||||
|
./scripts/agentrun provider-profiles remove <profile>
|
||||||
./scripts/agentrun provider-profiles set-key <profile> --key-stdin
|
./scripts/agentrun provider-profiles set-key <profile> --key-stdin
|
||||||
./scripts/agentrun provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]
|
./scripts/agentrun provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]
|
||||||
./scripts/agentrun backends list
|
./scripts/agentrun backends list
|
||||||
@@ -89,7 +90,7 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
|
|||||||
- `server logs` 必须返回有界日志尾部、bytes、truncated 和 logPath;找不到日志文件时也必须返回非空 JSON。
|
- `server logs` 必须返回有界日志尾部、bytes、truncated 和 logPath;找不到日志文件时也必须返回非空 JSON。
|
||||||
- `server stop` 必须按 pidFile 与端口进程清理本地 manager,并返回 before/after 状态;不得要求人工用 `ps/kill/ss` 组合命令清理常见临时服务。
|
- `server stop` 必须按 pidFile 与端口进程清理本地 manager,并返回 before/after 状态;不得要求人工用 `ps/kill/ss` 组合命令清理常见临时服务。
|
||||||
- `secrets codex render --dry-run` 返回 Codex stdio profile Secret 创建计划、输入文件 bytes/hash、SecretRef、manifest 摘要和 apply 命令形状;`--profile codex` 默认 Secret name 为 `agentrun-v01-provider-codex`,`--profile deepseek` 默认 Secret name 为 `agentrun-v01-provider-deepseek`,`--profile minimax-m3` 默认 Secret name 为 `agentrun-v01-provider-minimax-m3`;它不得输出 Secret value 或执行 Kubernetes 写操作。
|
- `secrets codex render --dry-run` 返回 Codex stdio profile Secret 创建计划、输入文件 bytes/hash、SecretRef、manifest 摘要和 apply 命令形状;`--profile codex` 默认 Secret name 为 `agentrun-v01-provider-codex`,`--profile deepseek` 默认 Secret name 为 `agentrun-v01-provider-deepseek`,`--profile minimax-m3` 默认 Secret name 为 `agentrun-v01-provider-minimax-m3`;它不得输出 Secret value 或执行 Kubernetes 写操作。
|
||||||
- `provider-profiles` 命令族调用 manager REST 管理 API,覆盖 profile status、API Key 写入和 canary 验证。`set-key --key-stdin` 从 stdin 读取 API Key,响应只显示 SecretRef、resourceVersion、hash 后缀和 failureKind;不得输出 key、Codex auth/config 或 Secret data。
|
- `provider-profiles` 命令族调用 manager REST 管理 API,覆盖 profile status、删除、API Key 写入和 canary 验证。`set-key --key-stdin` 从 stdin 读取 API Key,响应只显示 SecretRef、resourceVersion、hash 后缀和 failureKind;不得输出 key、Codex auth/config 或 Secret data。
|
||||||
- `backends list` 必须显示 `codex`、`deepseek` 与 `minimax-m3` profile 的 backendKind、protocol、transport、command、requiredSecretKeys 和状态;不得因为某个 provider Secret 尚未配置就隐藏 capability。
|
- `backends list` 必须显示 `codex`、`deepseek` 与 `minimax-m3` profile 的 backendKind、protocol、transport、command、requiredSecretKeys 和状态;不得因为某个 provider Secret 尚未配置就隐藏 capability。
|
||||||
- `queue dispatch` 是 Q2 的受控手动调度入口,只对单个 task 显式创建 attempt 和 Core run/command/runner job;不得伪装成自动 scheduler。
|
- `queue dispatch` 是 Q2 的受控手动调度入口,只对单个 task 显式创建 attempt 和 Core run/command/runner job;不得伪装成自动 scheduler。
|
||||||
- `queue refresh` 只根据 Queue task 中保存的 Core run/command 引用回写 Queue attempt 状态,不读取 Core trace 反推 commander 或统计。
|
- `queue refresh` 只根据 Queue task 中保存的 Core run/command 引用回写 Queue attempt 状态,不读取 Core trace 反推 commander 或统计。
|
||||||
@@ -131,7 +132,7 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
|
|||||||
|
|
||||||
### T5.1 Provider profile 管理 CLI
|
### T5.1 Provider profile 管理 CLI
|
||||||
|
|
||||||
阅读本文和 [spec-v01-provider-profile-management.md](spec-v01-provider-profile-management.md),然后用 `./scripts/agentrun provider-profiles list`、`set-key deepseek --key-stdin` 和 `validate deepseek --wait` 验证 profile 管理闭环。确认 CLI 调 manager REST,不直连 Postgres,不读取 Kubernetes Secret value;输出包含 validationId/runId/commandId/jobName/resourceVersion/hash 后缀,且不包含 API Key、Codex auth/config 或 Secret data。
|
阅读本文和 [spec-v01-provider-profile-management.md](spec-v01-provider-profile-management.md),然后用 `./scripts/agentrun provider-profiles list`、`remove <profile>`、`set-key deepseek --key-stdin` 和 `validate deepseek --wait` 验证 profile 管理闭环。确认 CLI 调 manager REST,不直连 Postgres,不读取 Kubernetes Secret value;输出包含 validationId/runId/commandId/jobName/resourceVersion/hash 后缀,且不包含 API Key、Codex auth/config 或 Secret data。
|
||||||
|
|
||||||
### T6 Queue 与 Session CLI 分层
|
### T6 Queue 与 Session CLI 分层
|
||||||
|
|
||||||
@@ -153,5 +154,5 @@ CLI 官方 TypeScript 入口固定为 `scripts/agentrun-cli.ts`。在 G14 非交
|
|||||||
| Session CLI | 已实现/Q3 | 已提供 `sessions ps/show/turn/steer/cancel/trace/output/read`;默认 ps 只显示 running/unread,terminal 后自动 unread,read cursor 由 CLI 标记。 |
|
| Session CLI | 已实现/Q3 | 已提供 `sessions ps/show/turn/steer/cancel/trace/output/read`;默认 ps 只显示 running/unread,terminal 后自动 unread,read cursor 由 CLI 标记。 |
|
||||||
| CLI 测试规格 | 已定义/已验证主闭环 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md);每次发布仍按手动交互验收复跑。 |
|
| CLI 测试规格 | 已定义/已验证主闭环 | 综合联调见 [spec-v01-validation.md](spec-v01-validation.md);每次发布仍按手动交互验收复跑。 |
|
||||||
| `deepseek` profile CLI | 已实现/已通过主闭环 | `secrets codex render --profile deepseek`、`backends list`、`runner start --backend`、`runner job` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调已通过 `codex -> deepseek -> codex` 切换主闭环。 |
|
| `deepseek` profile CLI | 已实现/已通过主闭环 | `secrets codex render --profile deepseek`、`backends list`、`runner start --backend`、`runner job` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调已通过 `codex -> deepseek -> codex` 切换主闭环。 |
|
||||||
| Provider profile 管理 CLI | 已实现 | `provider-profiles list/show/set-key/validate` 调用 manager REST API,用于 HWLAB 委托和 operator 验收;输出必须持续保持 Secret/API Key 脱敏。 |
|
| Provider profile 管理 CLI | 已实现 | `provider-profiles list/show/remove/set-key/validate` 调用 manager REST API,用于 HWLAB 委托和 operator 验收;输出必须持续保持 Secret/API Key 脱敏。 |
|
||||||
| `minimax-m3` profile CLI | 已实现/待真实主闭环 | `secrets codex render --profile minimax-m3`、`backends list`、`runner start --backend`、`runner job`、`sessions turn --profile minimax-m3|M3` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调需要按 `codex -> deepseek -> minimax-m3 -> codex` 手动验收。 |
|
| `minimax-m3` profile CLI | 已实现/待真实主闭环 | `secrets codex render --profile minimax-m3`、`backends list`、`runner start --backend`、`runner job`、`sessions turn --profile minimax-m3|M3` 和 JSON 错误可见性已实现;真实 CLI/RESTful 联调需要按 `codex -> deepseek -> minimax-m3 -> codex` 手动验收。 |
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Provider profile 管理 API 属于 `agentrun-mgr` 公共 REST API 的服务端
|
|||||||
```http
|
```http
|
||||||
GET /api/v1/provider-profiles
|
GET /api/v1/provider-profiles
|
||||||
GET /api/v1/provider-profiles/:profile
|
GET /api/v1/provider-profiles/:profile
|
||||||
|
DELETE /api/v1/provider-profiles/:profile
|
||||||
GET /api/v1/provider-profiles/:profile/config
|
GET /api/v1/provider-profiles/:profile/config
|
||||||
PUT /api/v1/provider-profiles/:profile/config
|
PUT /api/v1/provider-profiles/:profile/config
|
||||||
PUT /api/v1/provider-profiles/:profile/credential
|
PUT /api/v1/provider-profiles/:profile/credential
|
||||||
@@ -65,6 +66,14 @@ GET /api/v1/provider-profiles/:profile/validations/:validationId
|
|||||||
|
|
||||||
Secret 缺失时仍要返回 profile capability,并把状态标为 `configured=false` 或 `failureKind=secret-unavailable`;不得因为 Secret 未配置而隐藏 profile。
|
Secret 缺失时仍要返回 profile capability,并把状态标为 `configured=false` 或 `failureKind=secret-unavailable`;不得因为 Secret 未配置而隐藏 profile。
|
||||||
|
|
||||||
|
### `DELETE /api/v1/provider-profiles/:profile`
|
||||||
|
|
||||||
|
删除 profile 对应 Kubernetes Secret。
|
||||||
|
|
||||||
|
- 内建 profile(`codex`、`deepseek`、`minimax-m3`、`dsflash-go`)删除后,capability 仍必须保留在 `GET /api/v1/provider-profiles` 列表中,但状态回到 `configured=false` / `failureKind=secret-unavailable`。
|
||||||
|
- 动态 slug 删除后,若没有剩余 Secret,对应 slug 不再出现在 collection list 中;显式 `GET /api/v1/provider-profiles/:profile` 仍可返回该 slug 的未配置状态。
|
||||||
|
- 响应必须返回 `removed` 或 `alreadyAbsent`,并保持 Secret/API Key 脱敏。
|
||||||
|
|
||||||
### `GET/PUT /api/v1/provider-profiles/:profile/config`
|
### `GET/PUT /api/v1/provider-profiles/:profile/config`
|
||||||
|
|
||||||
`GET` 返回当前 profile 的 `configToml`、SecretRef、resourceVersion 和 hash 后缀,供 HWLAB admin 管理页查看。`PUT` 接收 `configToml`,保存时只替换同一 Secret 的 `config.toml`,保留现有 `auth.json`,并返回 resourceVersion 和 `configHashSuffix`。
|
`GET` 返回当前 profile 的 `configToml`、SecretRef、resourceVersion 和 hash 后缀,供 HWLAB admin 管理页查看。`PUT` 接收 `configToml`,保存时只替换同一 Secret 的 `config.toml`,保留现有 `auth.json`,并返回 resourceVersion 和 `configHashSuffix`。
|
||||||
@@ -150,6 +159,7 @@ AgentRun CLI 提供 operator 和综合联调入口:
|
|||||||
./scripts/agentrun provider-profiles list
|
./scripts/agentrun provider-profiles list
|
||||||
./scripts/agentrun provider-profiles show deepseek
|
./scripts/agentrun provider-profiles show deepseek
|
||||||
./scripts/agentrun provider-profiles config deepseek
|
./scripts/agentrun provider-profiles config deepseek
|
||||||
|
./scripts/agentrun provider-profiles remove deepseek
|
||||||
./scripts/agentrun provider-profiles set-key deepseek --key-stdin
|
./scripts/agentrun provider-profiles set-key deepseek --key-stdin
|
||||||
./scripts/agentrun provider-profiles set-config deepseek --config-stdin
|
./scripts/agentrun provider-profiles set-config deepseek --config-stdin
|
||||||
./scripts/agentrun provider-profiles validate deepseek --wait --timeout-ms 120000
|
./scripts/agentrun provider-profiles validate deepseek --wait --timeout-ms 120000
|
||||||
@@ -183,13 +193,17 @@ Manager 审计事件允许记录:profile、action、delegatedBy.system、deleg
|
|||||||
|
|
||||||
检查 manager 日志、AgentRun events、CLI 输出和 validation result,确认不包含 API Key 原文、Codex `auth.json`、`config.toml`、Secret data 或 Authorization header。
|
检查 manager 日志、AgentRun events、CLI 输出和 validation result,确认不包含 API Key 原文、Codex `auth.json`、`config.toml`、Secret data 或 Authorization header。
|
||||||
|
|
||||||
|
### T6 profile 删除
|
||||||
|
|
||||||
|
用 `./scripts/agentrun provider-profiles remove <profile>` 删除一个动态 slug,再删除一个内建 profile。确认动态 slug 从 collection list 消失;内建 profile 仍留在 list 中但 `configured=false`;CLI/日志/响应不输出 Secret value。
|
||||||
|
|
||||||
## 实现状态
|
## 实现状态
|
||||||
|
|
||||||
| 能力 | 状态 | 说明 |
|
| 能力 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Provider profile 管理规格 | 已定义/已落地 | 本文为 AgentRun `v0.1` profile 管理权威规格。 |
|
| Provider profile 管理规格 | 已定义/已落地 | 本文为 AgentRun `v0.1` profile 管理权威规格。 |
|
||||||
| REST 管理 API | 已实现 | `agentrun-mgr` 提供 `/api/v1/provider-profiles*`,覆盖 list/show/set-key/validate/validation。 |
|
| REST 管理 API | 已实现 | `agentrun-mgr` 提供 `/api/v1/provider-profiles*`,覆盖 list/show/remove/set-key/validate/validation。 |
|
||||||
| CLI 管理入口 | 已实现 | `./scripts/agentrun provider-profiles list/show/set-key/validate` 调用 manager REST API,不直连 Secret value。 |
|
| CLI 管理入口 | 已实现 | `./scripts/agentrun provider-profiles list/show/remove/set-key/validate` 调用 manager REST API,不直连 Secret value。 |
|
||||||
| DeepSeek Secret 写入 | 已实现/需硬化 | 已按受控 SecretRef 更新 `auth.json`/`config.toml` 并保持 HWLAB Moon Bridge 官方链路;后续必须去除 credential update 产生 `last-applied-configuration` 注解的副作用。 |
|
| DeepSeek Secret 写入 | 已实现/需硬化 | 已按受控 SecretRef 更新 `auth.json`/`config.toml` 并保持 HWLAB Moon Bridge 官方链路;后续必须去除 credential update 产生 `last-applied-configuration` 注解的副作用。 |
|
||||||
| Provider canary | 已实现 | canary 通过真实 run/command/runner-job 路径执行,并返回 validationId、runId、commandId、jobName 和 terminal status。 |
|
| Provider canary | 已实现 | canary 通过真实 run/command/runner-job 路径执行,并返回 validationId、runId、commandId、jobName 和 terminal status。 |
|
||||||
| HWLAB 委托信任边界 | 已验证 | HWLAB v0.2 通过 Cloud API 委托调用本 API;AgentRun 不读取 HWLAB Web session,也不做用户级鉴权。 |
|
| HWLAB 委托信任边界 | 已验证 | HWLAB v0.2 通过 Cloud API 委托调用本 API;AgentRun 不读取 HWLAB Web session,也不做用户级鉴权。 |
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async function dispatch(args: ParsedArgs): Promise<JsonValue> {
|
|||||||
if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles");
|
if (group === "provider-profiles" && command === "list") return client(args).get("/api/v1/provider-profiles");
|
||||||
if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`);
|
if (group === "provider-profiles" && command === "show" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}`);
|
||||||
if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`);
|
if (group === "provider-profiles" && command === "config" && id) return client(args).get(`/api/v1/provider-profiles/${encodeURIComponent(normalizeProfile(id))}/config`);
|
||||||
|
if (group === "provider-profiles" && (command === "remove" || command === "delete" || command === "rm") && id) return removeProviderProfileCli(id, args);
|
||||||
if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id);
|
if (group === "provider-profiles" && command === "set-key" && id) return setProviderProfileKey(args, id);
|
||||||
if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id);
|
if (group === "provider-profiles" && command === "set-config" && id) return setProviderProfileConfig(args, id);
|
||||||
if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id);
|
if (group === "provider-profiles" && command === "validate" && id) return validateProviderProfileCli(args, id);
|
||||||
@@ -438,6 +439,11 @@ async function setProviderProfileConfig(args: ParsedArgs, profileValue: string):
|
|||||||
}) as JsonRecord;
|
}) as JsonRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeProviderProfileCli(profileValue: string, args: ParsedArgs): Promise<JsonRecord> {
|
||||||
|
const profile = normalizeProfile(profileValue);
|
||||||
|
return await client(args).delete(`/api/v1/provider-profiles/${encodeURIComponent(profile)}`) as JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
async function validateProviderProfileCli(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
async function validateProviderProfileCli(args: ParsedArgs, profileValue: string): Promise<JsonRecord> {
|
||||||
const profile = normalizeProfile(profileValue);
|
const profile = normalizeProfile(profileValue);
|
||||||
const started = await client(args).post(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validate`, {}) as JsonRecord;
|
const started = await client(args).post(`/api/v1/provider-profiles/${encodeURIComponent(profile)}/validate`, {}) as JsonRecord;
|
||||||
@@ -790,6 +796,7 @@ function help(): JsonRecord {
|
|||||||
"provider-profiles list",
|
"provider-profiles list",
|
||||||
"provider-profiles show <profile>",
|
"provider-profiles show <profile>",
|
||||||
"provider-profiles config <profile>",
|
"provider-profiles config <profile>",
|
||||||
|
"provider-profiles remove <profile>",
|
||||||
"provider-profiles set-key <profile> --key-stdin [--model <model>] [--base-url <url>]",
|
"provider-profiles set-key <profile> --key-stdin [--model <model>] [--base-url <url>]",
|
||||||
"provider-profiles set-config <profile> --config-stdin",
|
"provider-profiles set-config <profile> --config-stdin",
|
||||||
"provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]",
|
"provider-profiles validate <profile> [--wait] [--timeout-ms <ms>]",
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ metadata:
|
|||||||
rules:
|
rules:
|
||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["secrets"]
|
resources: ["secrets"]
|
||||||
verbs: ["create", "get", "list", "patch", "update"]
|
verbs: ["create", "delete", "get", "list", "patch", "update"]
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
|
|||||||
@@ -75,6 +75,37 @@ export async function getProviderProfileConfig(profileValue: string, options: Pr
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeProviderProfile(profileValue: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
||||||
|
const profile = validateBackendProfile(profileValue);
|
||||||
|
const spec = requiredSpec(profile);
|
||||||
|
const namespace = profileNamespace(options);
|
||||||
|
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
||||||
|
const data = asOptionalRecord(secret?.data);
|
||||||
|
const annotations = asOptionalRecord(asOptionalRecord(secret?.metadata)?.annotations);
|
||||||
|
if (secret) await kubectlDeleteSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
|
||||||
|
return {
|
||||||
|
action: "provider-profile-removed",
|
||||||
|
mutation: true,
|
||||||
|
profile,
|
||||||
|
configured: false,
|
||||||
|
removed: Boolean(secret),
|
||||||
|
...(secret ? {} : { alreadyAbsent: true }),
|
||||||
|
...(isBuiltinProviderProfile(profile) ? { builtinCapabilityRetained: true } : {}),
|
||||||
|
secretRef: secretRefSummary(profile, namespace),
|
||||||
|
deletedResourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
|
||||||
|
credentialHashSuffix: hashDataKey(data, "auth.json") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-credential-hash-suffix`]),
|
||||||
|
configHashSuffix: hashDataKey(data, "config.toml") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-config-hash-suffix`]),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
valuesPrinted: false,
|
||||||
|
pollCommands: {
|
||||||
|
list: "./scripts/agentrun provider-profiles list",
|
||||||
|
show: `./scripts/agentrun provider-profiles show ${profile}`,
|
||||||
|
setKey: `./scripts/agentrun provider-profiles set-key ${profile} --key-stdin`,
|
||||||
|
setConfig: `./scripts/agentrun provider-profiles set-config ${profile} --config-stdin`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function setProviderProfileConfig(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
export async function setProviderProfileConfig(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
|
||||||
const profile = validateBackendProfile(profileValue);
|
const profile = validateBackendProfile(profileValue);
|
||||||
const spec = requiredSpec(profile);
|
const spec = requiredSpec(profile);
|
||||||
@@ -304,6 +335,10 @@ function compareProviderProfiles(left: string, right: string): number {
|
|||||||
return left.localeCompare(right);
|
return left.localeCompare(right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBuiltinProviderProfile(profile: BackendProfile): boolean {
|
||||||
|
return backendProfileSpecs.some((item) => item.profile === profile);
|
||||||
|
}
|
||||||
|
|
||||||
function providerProfileFromSecret(secret: JsonRecord): BackendProfile | null {
|
function providerProfileFromSecret(secret: JsonRecord): BackendProfile | null {
|
||||||
const metadata = asOptionalRecord(secret.metadata);
|
const metadata = asOptionalRecord(secret.metadata);
|
||||||
const labels = asOptionalRecord(metadata?.labels);
|
const labels = asOptionalRecord(metadata?.labels);
|
||||||
@@ -615,6 +650,13 @@ async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string)
|
|||||||
throw new AgentRunError("infra-failed", `kubectl replace provider profile secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) });
|
throw new AgentRunError("infra-failed", `kubectl replace provider profile secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function kubectlDeleteSecret(name: string, namespace: string, kubectlCommand: string): Promise<void> {
|
||||||
|
const result = await runKubectl(kubectlCommand, ["delete", "secret", name, "-n", namespace, "--ignore-not-found=true"]);
|
||||||
|
if (result.code !== 0) {
|
||||||
|
throw new AgentRunError("infra-failed", `kubectl delete provider profile secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
|
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
|
||||||
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
|
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -14,7 +14,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js";
|
|||||||
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js";
|
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js";
|
||||||
import type { SessionPvcSummary } from "./session-pvc.js";
|
import type { SessionPvcSummary } from "./session-pvc.js";
|
||||||
import type { SessionPvcOptions } from "./session-pvc.js";
|
import type { SessionPvcOptions } from "./session-pvc.js";
|
||||||
import { getProviderProfileConfig, getProviderProfileValidation, listProviderProfiles, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
import { getProviderProfileConfig, getProviderProfileValidation, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
|
||||||
|
|
||||||
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
|
||||||
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
|
||||||
@@ -96,6 +96,7 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
|
|||||||
if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue;
|
if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue;
|
||||||
const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u);
|
const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u);
|
||||||
if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
||||||
|
if (method === "DELETE" && providerProfileMatch) return await removeProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
||||||
const providerConfigMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/config$/u);
|
const providerConfigMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/config$/u);
|
||||||
if (method === "GET" && providerConfigMatch) return await getProviderProfileConfig(providerConfigMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
if (method === "GET" && providerConfigMatch) return await getProviderProfileConfig(providerConfigMatch[1] ?? "", providerProfileDefaults) as JsonValue;
|
||||||
if (method === "PUT" && providerConfigMatch) return await setProviderProfileConfig(providerConfigMatch[1] ?? "", body, providerProfileDefaults) as JsonValue;
|
if (method === "PUT" && providerConfigMatch) return await setProviderProfileConfig(providerConfigMatch[1] ?? "", body, providerProfileDefaults) as JsonValue;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const secretText = "sk-selftest-provider-profile-secret";
|
|||||||
const selfTest: SelfTestCase = async (context) => {
|
const selfTest: SelfTestCase = async (context) => {
|
||||||
const gitopsRenderer = await readFile(path.join(context.root, "scripts/src/gitops-render.ts"), "utf8");
|
const gitopsRenderer = await readFile(path.join(context.root, "scripts/src/gitops-render.ts"), "utf8");
|
||||||
assert.equal(gitopsRenderer.includes("agentrun-v01-mgr-provider-secret-manager"), true);
|
assert.equal(gitopsRenderer.includes("agentrun-v01-mgr-provider-secret-manager"), true);
|
||||||
assert.equal(gitopsRenderer.includes('verbs: ["create", "get", "list", "patch", "update"]'), true);
|
assert.equal(gitopsRenderer.includes('verbs: ["create", "delete", "get", "list", "patch", "update"]'), true);
|
||||||
assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]'), false);
|
assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]'), false);
|
||||||
assert.equal(gitopsRenderer.includes('resources: ["secrets"]'), true);
|
assert.equal(gitopsRenderer.includes('resources: ["secrets"]'), true);
|
||||||
|
|
||||||
@@ -23,9 +23,11 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
|
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
|
||||||
const secretStateDir = path.join(context.tmp, "provider-secret-state");
|
const secretStateDir = path.join(context.tmp, "provider-secret-state");
|
||||||
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
||||||
|
import { rmSync } from "node:fs";
|
||||||
const args = Bun.argv.slice(2);
|
const args = Bun.argv.slice(2);
|
||||||
const secretStateDir = ${JSON.stringify(secretStateDir)};
|
const secretStateDir = ${JSON.stringify(secretStateDir)};
|
||||||
const secretStatePath = (name) => secretStateDir + "/" + name + ".json";
|
const secretStatePath = (name) => secretStateDir + "/" + name + ".json";
|
||||||
|
const deletedMarkerPath = (name) => secretStateDir + "/" + name + ".deleted";
|
||||||
const fixtureSecret = (name) => ({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\n").toString("base64") } });
|
const fixtureSecret = (name) => ({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\n").toString("base64") } });
|
||||||
const readStdin = async () => {
|
const readStdin = async () => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
@@ -34,7 +36,12 @@ const readStdin = async () => {
|
|||||||
};
|
};
|
||||||
if (args[0] === "get" && args[1] === "secret") {
|
if (args[0] === "get" && args[1] === "secret") {
|
||||||
const name = args[2];
|
const name = args[2];
|
||||||
|
const deletedMarker = Bun.file(deletedMarkerPath(name));
|
||||||
const stateFile = Bun.file(secretStatePath(name));
|
const stateFile = Bun.file(secretStatePath(name));
|
||||||
|
if (await deletedMarker.exists()) {
|
||||||
|
console.error('Error from server (NotFound): secrets "' + name + '" not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
if (await stateFile.exists()) {
|
if (await stateFile.exists()) {
|
||||||
console.log(await stateFile.text());
|
console.log(await stateFile.text());
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
@@ -49,11 +56,14 @@ if (args[0] === "get" && args[1] === "secret") {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (args[0] === "get" && args[1] === "secrets") {
|
if (args[0] === "get" && args[1] === "secrets") {
|
||||||
const items = [fixtureSecret("agentrun-v01-provider-codex"), fixtureSecret("agentrun-v01-provider-deepseek"), fixtureSecret("agentrun-v01-provider-minimax-m3")];
|
const items = [];
|
||||||
|
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-codex")).exists())) items.push(fixtureSecret("agentrun-v01-provider-codex"));
|
||||||
|
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-deepseek")).exists())) items.push(fixtureSecret("agentrun-v01-provider-deepseek"));
|
||||||
|
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-minimax-m3")).exists())) items.push(fixtureSecret("agentrun-v01-provider-minimax-m3"));
|
||||||
const dsflashState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go"));
|
const dsflashState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go"));
|
||||||
if (await dsflashState.exists()) items.push(JSON.parse(await dsflashState.text()));
|
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-dsflash-go")).exists()) && await dsflashState.exists()) items.push(JSON.parse(await dsflashState.text()));
|
||||||
const dynamicState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go-cli-selftest"));
|
const dynamicState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go-cli-selftest"));
|
||||||
if (await dynamicState.exists()) items.push(JSON.parse(await dynamicState.text()));
|
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-dsflash-go-cli-selftest")).exists()) && await dynamicState.exists()) items.push(JSON.parse(await dynamicState.text()));
|
||||||
console.log(JSON.stringify({ apiVersion: "v1", kind: "SecretList", items }));
|
console.log(JSON.stringify({ apiVersion: "v1", kind: "SecretList", items }));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -79,6 +89,7 @@ if (args[0] === "patch" && args[1] === "secret") {
|
|||||||
if (args[0] === "replace") {
|
if (args[0] === "replace") {
|
||||||
const text = await readStdin();
|
const text = await readStdin();
|
||||||
const manifest = JSON.parse(text);
|
const manifest = JSON.parse(text);
|
||||||
|
try { rmSync(deletedMarkerPath(manifest.metadata.name)); } catch {}
|
||||||
const stateFile = Bun.file(secretStatePath(manifest.metadata.name));
|
const stateFile = Bun.file(secretStatePath(manifest.metadata.name));
|
||||||
if (!(await stateFile.exists())) {
|
if (!(await stateFile.exists())) {
|
||||||
console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found');
|
console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found');
|
||||||
@@ -94,6 +105,7 @@ if (args[0] === "create") {
|
|||||||
const text = await readStdin();
|
const text = await readStdin();
|
||||||
const manifest = JSON.parse(text);
|
const manifest = JSON.parse(text);
|
||||||
if (manifest.kind === "Secret") {
|
if (manifest.kind === "Secret") {
|
||||||
|
try { rmSync(deletedMarkerPath(manifest.metadata.name)); } catch {}
|
||||||
const annotations = manifest.metadata?.annotations ?? {};
|
const annotations = manifest.metadata?.annotations ?? {};
|
||||||
await Bun.write(${JSON.stringify(createdSecretPath)}, JSON.stringify({ args, manifest }, null, 2));
|
await Bun.write(${JSON.stringify(createdSecretPath)}, JSON.stringify({ args, manifest }, null, 2));
|
||||||
await Bun.write(secretStatePath(manifest.metadata.name), JSON.stringify({ ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-created", annotations } }));
|
await Bun.write(secretStatePath(manifest.metadata.name), JSON.stringify({ ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-created", annotations } }));
|
||||||
@@ -104,6 +116,13 @@ if (args[0] === "create") {
|
|||||||
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-provider-validation", resourceVersion: "rv-job", name: manifest.metadata.name, namespace: manifest.metadata.namespace } }));
|
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-provider-validation", resourceVersion: "rv-job", name: manifest.metadata.name, namespace: manifest.metadata.namespace } }));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
if (args[0] === "delete" && args[1] === "secret") {
|
||||||
|
const name = args[2];
|
||||||
|
try { rmSync(secretStatePath(name)); } catch {}
|
||||||
|
await Bun.write(deletedMarkerPath(name), "deleted\n");
|
||||||
|
console.log(JSON.stringify({ kind: "Status", status: "Success", details: { name } }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
console.error("unsupported fake kubectl args: " + JSON.stringify(args));
|
console.error("unsupported fake kubectl args: " + JSON.stringify(args));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
`);
|
`);
|
||||||
@@ -256,6 +275,30 @@ process.exit(1);
|
|||||||
assert.equal(dynamicListItems.some((item) => item.profile === dynamicProfile), true);
|
assert.equal(dynamicListItems.some((item) => item.profile === dynamicProfile), true);
|
||||||
assertNoSecretLeak(dynamicCredential);
|
assertNoSecretLeak(dynamicCredential);
|
||||||
|
|
||||||
|
const removedDeepseek = await client.delete("/api/v1/provider-profiles/deepseek") as JsonRecord;
|
||||||
|
assert.equal(removedDeepseek.profile, "deepseek");
|
||||||
|
assert.equal(removedDeepseek.removed, true);
|
||||||
|
assert.equal(removedDeepseek.builtinCapabilityRetained, true);
|
||||||
|
assertNoSecretLeak(removedDeepseek);
|
||||||
|
const deepseekShownAfterRemove = await client.get("/api/v1/provider-profiles/deepseek") as JsonRecord;
|
||||||
|
assert.equal(deepseekShownAfterRemove.configured, false);
|
||||||
|
assert.equal(deepseekShownAfterRemove.failureKind, "secret-unavailable");
|
||||||
|
const listAfterBuiltinRemove = await client.get("/api/v1/provider-profiles") as JsonRecord;
|
||||||
|
assert.equal(listAfterBuiltinRemove.count, 5);
|
||||||
|
const deepseekAfterRemove = ((listAfterBuiltinRemove.items as JsonRecord[]) ?? []).find((item) => item.profile === "deepseek") as JsonRecord | undefined;
|
||||||
|
assert.equal(deepseekAfterRemove?.configured, false);
|
||||||
|
assert.equal(deepseekAfterRemove?.failureKind, "secret-unavailable");
|
||||||
|
|
||||||
|
const removedDynamic = await client.delete(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}`) as JsonRecord;
|
||||||
|
assert.equal(removedDynamic.profile, dynamicProfile);
|
||||||
|
assert.equal(removedDynamic.removed, true);
|
||||||
|
assert.equal(removedDynamic.builtinCapabilityRetained, undefined);
|
||||||
|
assertNoSecretLeak(removedDynamic);
|
||||||
|
const listAfterDynamicRemove = await client.get("/api/v1/provider-profiles") as JsonRecord;
|
||||||
|
assert.equal(listAfterDynamicRemove.count, 4);
|
||||||
|
const itemsAfterDynamicRemove = (listAfterDynamicRemove.items as JsonRecord[]) ?? [];
|
||||||
|
assert.equal(itemsAfterDynamicRemove.some((item) => item.profile === dynamicProfile), false);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }),
|
() => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }),
|
||||||
(error) => error instanceof Error && error.message.includes("not hyueapi.com"),
|
(error) => error instanceof Error && error.message.includes("not hyueapi.com"),
|
||||||
@@ -279,7 +322,7 @@ process.exit(1);
|
|||||||
assert.equal(finalValidation.status, "completed");
|
assert.equal(finalValidation.status, "completed");
|
||||||
assert.equal(JSON.stringify(finalValidation).includes(secretText), false);
|
assert.equal(JSON.stringify(finalValidation).includes(secretText), false);
|
||||||
assertNoSecretLeak(finalValidation);
|
assertNoSecretLeak(finalValidation);
|
||||||
return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-config", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dynamic-slug-roundtrip", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] };
|
return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-config", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dynamic-slug-roundtrip", "provider-profile-remove-builtin", "provider-profile-remove-dynamic-slug", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] };
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user