对接 agent_swarm PR#43 定死的参数,实现 HM 侧的 per-user 模型 key 注入:
- A.1 粒度/命名:为认证用户 mint 一把 per-user sk-(getOrMintSwarmModelToken),
跨该用户所有 swarm run 复用;KV 密文名 swarm-model-key-<user_id>。
- A.2 KV value:JSON {"openai_api_key":"sk-..."},对齐 Swarm
orchestrator/agent_launcher._extract_model_key 解析字段。
- A.4 OPENAI_API_BASE 为 Swarm 部署常量,HM 不经 billing_context 下发。
- A.5 吊销:事件驱动。注册 swarm.pool_terminated(swarm_lifecycle 类),
回调 handleSwarmPoolTerminated 删 KV 密文 + 软删 token;user_id 优先取
payload,缺失回退 deployment 上下文。
createAgentDeploymentFromPlan 在 swarm 模式且 billing_context 未自带 secret_ref
时调 provisionSwarmModelKey,把 azkv:// 引用写进 billing_context.secret_ref 下发。
Key Vault 未配置/不可达时降级:记日志、secret_ref 留空,不阻断 create(联调前可用)。
明文 sk- 仅经 secret_ref 服务端解析,绝不入代码/日志/事件/argv。
测试:swarm_model_key_test.go 覆盖 per-user 复用、吊销重 mint、pool_terminated
回调(payload/上下文两路 user_id)、非目标事件不误吊销、事件已注册。
影响面:agent_swarm(契约消费侧)、密钥(per-user sk- mint+吊销)、计费
(走 User.Quota)、审计(回调入库+审计事件)。不影响 Client/CodeGW/发布链路。
未决依赖:A.3(Swarm 读 heicode-kv 的 RBAC,运维授权 pending)是端到端联调前置,
不阻塞本 PR。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
140 lines
4.6 KiB
Go
140 lines
4.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
)
|
|
|
|
func setupSwarmModelKeyTestDB(t *testing.T) {
|
|
t.Helper()
|
|
common.UsingSQLite = true
|
|
common.UsingMySQL = false
|
|
common.UsingPostgreSQL = false
|
|
common.RedisEnabled = false
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
model.DB = db
|
|
require.NoError(t, db.AutoMigrate(&model.Token{}))
|
|
t.Cleanup(func() {
|
|
if sqlDB, err := db.DB(); err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
model.DB = nil
|
|
})
|
|
}
|
|
|
|
func TestSwarmModelKeyNames(t *testing.T) {
|
|
require.Equal(t, "swarm:user:42", swarmModelKeyTokenName(42))
|
|
require.Equal(t, "swarm-model-key-42", swarmModelKeySecretName(42))
|
|
}
|
|
|
|
func TestGetOrMintSwarmModelTokenReusesPerUser(t *testing.T) {
|
|
setupSwarmModelKeyTestDB(t)
|
|
|
|
// A.1: first call mints, second call reuses the SAME sk- across runs.
|
|
first, err := getOrMintSwarmModelToken(7001)
|
|
require.NoError(t, err)
|
|
require.True(t, strings.HasPrefix(first, "sk-"))
|
|
second, err := getOrMintSwarmModelToken(7001)
|
|
require.NoError(t, err)
|
|
require.Equal(t, first, second, "per-user swarm key must be reused across runs")
|
|
|
|
// only one token persisted for the user, and it is hidden + system-managed.
|
|
var toks []model.Token
|
|
require.NoError(t, model.DB.Where("user_id = ?", 7001).Find(&toks).Error)
|
|
require.Len(t, toks, 1)
|
|
require.Equal(t, swarmModelKeyTokenName(7001), toks[0].Name)
|
|
require.True(t, toks[0].HideFromUserUI)
|
|
require.True(t, toks[0].UnlimitedQuota)
|
|
require.EqualValues(t, -1, toks[0].ExpiredTime)
|
|
|
|
// distinct users get distinct keys.
|
|
other, err := getOrMintSwarmModelToken(7002)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, first, other)
|
|
|
|
// invalid user rejected.
|
|
_, err = getOrMintSwarmModelToken(0)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestRevokeSwarmModelKeyReMintsFresh(t *testing.T) {
|
|
setupSwarmModelKeyTestDB(t)
|
|
|
|
first, err := getOrMintSwarmModelToken(7003)
|
|
require.NoError(t, err)
|
|
|
|
// revoke soft-deletes the token (KV not configured -> delete logged, non-fatal).
|
|
revokeSwarmModelKey(7003)
|
|
var live []model.Token
|
|
require.NoError(t, model.DB.Where("user_id = ?", 7003).Find(&live).Error)
|
|
require.Len(t, live, 0, "revoked swarm token must be soft-deleted")
|
|
|
|
// next provision mints a brand-new key (A.5: stop -> revoke -> new key on re-run).
|
|
second, err := getOrMintSwarmModelToken(7003)
|
|
require.NoError(t, err)
|
|
require.NotEqual(t, first, second)
|
|
}
|
|
|
|
func TestHandleSwarmPoolTerminatedRevokesByPayloadUserID(t *testing.T) {
|
|
setupSwarmModelKeyTestDB(t)
|
|
_, err := getOrMintSwarmModelToken(7004)
|
|
require.NoError(t, err)
|
|
|
|
payload := agentCallbackEnvelope{
|
|
EventType: "swarm.pool_terminated",
|
|
Payload: map[string]any{"user_id": "7004", "secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/swarm-model-key-7004"},
|
|
}
|
|
handleSwarmPoolTerminated(payload, agentDeploymentRecord{})
|
|
|
|
var live []model.Token
|
|
require.NoError(t, model.DB.Where("user_id = ?", 7004).Find(&live).Error)
|
|
require.Len(t, live, 0)
|
|
}
|
|
|
|
func TestHandleSwarmPoolTerminatedFallsBackToDeploymentUser(t *testing.T) {
|
|
setupSwarmModelKeyTestDB(t)
|
|
_, err := getOrMintSwarmModelToken(7005)
|
|
require.NoError(t, err)
|
|
|
|
// no user_id in payload -> fall back to deployment user context.
|
|
payload := agentCallbackEnvelope{EventType: "swarm.pool_terminated", Payload: map[string]any{}}
|
|
record := agentDeploymentRecord{}
|
|
record.Plan.UserContext.UserID = "7005"
|
|
handleSwarmPoolTerminated(payload, record)
|
|
|
|
var live []model.Token
|
|
require.NoError(t, model.DB.Where("user_id = ?", 7005).Find(&live).Error)
|
|
require.Len(t, live, 0)
|
|
}
|
|
|
|
func TestHandleSwarmPoolTerminatedIgnoresOtherEvents(t *testing.T) {
|
|
setupSwarmModelKeyTestDB(t)
|
|
_, err := getOrMintSwarmModelToken(7006)
|
|
require.NoError(t, err)
|
|
|
|
// wrong event type -> no revocation.
|
|
handleSwarmPoolTerminated(agentCallbackEnvelope{EventType: "swarm.stopped", Payload: map[string]any{"user_id": "7006"}}, agentDeploymentRecord{})
|
|
var live []model.Token
|
|
require.NoError(t, model.DB.Where("user_id = ?", 7006).Find(&live).Error)
|
|
require.Len(t, live, 1)
|
|
}
|
|
|
|
func TestSwarmPoolTerminatedEventRegistered(t *testing.T) {
|
|
// control-plane lifecycle event must be a known callback type so the schema
|
|
// validator accepts it and routes it to the swarm_lifecycle category.
|
|
_, hasFields := agentCallbackEventRequiredFields["swarm.pool_terminated"]
|
|
require.True(t, hasFields)
|
|
require.Equal(t, "swarm_lifecycle", agentCallbackEventCategories["swarm.pool_terminated"])
|
|
}
|