mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
Compare commits
16 Commits
v4.0.4-rc.
...
v4.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e18c0e07 | ||
|
|
7b80690bac | ||
|
|
9883a6afec | ||
|
|
d5f3c8752f | ||
|
|
45fc5cf0d0 | ||
|
|
64801c02c1 | ||
|
|
8fdbce23d6 | ||
|
|
82e3a751b2 | ||
|
|
fd4f251d7a | ||
|
|
d6dcae5da9 | ||
|
|
f18dd74131 | ||
|
|
730fa194a5 | ||
|
|
4af0712a1b | ||
|
|
0b04770576 | ||
|
|
94cff9d81e | ||
|
|
c47b7b3e06 |
@@ -36,6 +36,7 @@ import (
|
||||
)
|
||||
|
||||
const headerAiVirtualKey = "X-Ai-Virtual-Key"
|
||||
const headerAiRoutingId = "X-Ai-Routing-Id"
|
||||
|
||||
func extractVirtualKey(r *http.Request) string {
|
||||
if v := strings.TrimSpace(r.Header.Get(headerAiVirtualKey)); v != "" {
|
||||
@@ -49,6 +50,10 @@ func extractVirtualKey(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractRoutingId(r *http.Request) string {
|
||||
return strings.TrimSpace(r.Header.Get(headerAiRoutingId))
|
||||
}
|
||||
|
||||
func upstreamErrorStatusCode(uerr *upstream.Error) int {
|
||||
if uerr == nil || uerr.StatusCode <= 0 {
|
||||
return 0
|
||||
@@ -141,7 +146,7 @@ func chatCompletionsHandler(ctx context.Context, w http.ResponseWriter, r *http.
|
||||
|
||||
vk := extractVirtualKey(r)
|
||||
userCred := auth.AdminCredential()
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
|
||||
|
||||
@@ -71,7 +71,7 @@ func completionsHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ
|
||||
|
||||
vk := extractVirtualKey(r)
|
||||
userCred := auth.AdminCredential()
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
|
||||
|
||||
@@ -69,7 +69,7 @@ func embeddingsHandler(ctx context.Context, w http.ResponseWriter, r *http.Reque
|
||||
|
||||
vk := extractVirtualKey(r)
|
||||
userCred := auth.AdminCredential()
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
|
||||
|
||||
@@ -69,7 +69,7 @@ func imagesGenerationsHandler(ctx context.Context, w http.ResponseWriter, r *htt
|
||||
|
||||
vk := extractVirtualKey(r)
|
||||
userCred := auth.AdminCredential()
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
|
||||
|
||||
@@ -77,7 +77,7 @@ func messagesHandler(ctx context.Context, w http.ResponseWriter, r *http.Request
|
||||
|
||||
vk := extractVirtualKey(r)
|
||||
userCred := auth.AdminCredential()
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
|
||||
|
||||
@@ -97,7 +97,7 @@ func handleResponsesCreate(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
|
||||
vk := extractVirtualKey(r)
|
||||
userCred := auth.AdminCredential()
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, dict, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
failAPILogRecord(rec, http.StatusInternalServerError, "resolve_upstream", err)
|
||||
@@ -293,7 +293,7 @@ func handleResponsesSubResource(ctx context.Context, w http.ResponseWriter, r *h
|
||||
writeResponsesError(ctx, w, http.StatusBadRequest, "invalid_request_error", "model query parameter is required")
|
||||
return
|
||||
}
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, probe)
|
||||
up, err := models.ResolveChatUpstream(ctx, userCred, vk, probe, extractRoutingId(r))
|
||||
if err != nil {
|
||||
dbg.Error("resolve upstream: %v", err)
|
||||
httperrors.GeneralServerError(ctx, w, err)
|
||||
|
||||
@@ -233,9 +233,9 @@ func resolveUpstreamAPIKeyExcluding(prov *SAiProvider, modelKey string, exclude
|
||||
if hasSecretKey {
|
||||
detail := formatAiKeySkipReasons(skipReasons)
|
||||
if detail != "" {
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available ai_key for catalog model %q: %s", modelKey, detail)
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available backend for model %q: %s", modelKey, detail)
|
||||
}
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available ai_key for catalog model %q", modelKey)
|
||||
return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "no available backend for model %q", modelKey)
|
||||
}
|
||||
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "add an enabled ai_key with secret for this provider")
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/log"
|
||||
"yunion.io/x/pkg/errors"
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
"yunion.io/x/sqlchemy"
|
||||
@@ -35,6 +37,7 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
"yunion.io/x/onecloud/pkg/util/netutils2"
|
||||
"yunion.io/x/onecloud/pkg/util/stringutils2"
|
||||
)
|
||||
|
||||
@@ -162,6 +165,10 @@ func aiProxyNodeDisplayName(address string) string {
|
||||
return u.Host
|
||||
}
|
||||
|
||||
// detectLocalAdvertiseIP resolves a non-loopback local IP for advertise address.
|
||||
// Overridable in tests.
|
||||
var detectLocalAdvertiseIP = netutils2.MyIPSmart
|
||||
|
||||
// AdvertiseAddressFromOptions returns the service URL advertised by this instance.
|
||||
func AdvertiseAddressFromOptions(opts *options.SAiProxyOptions) (string, error) {
|
||||
if opts == nil {
|
||||
@@ -175,10 +182,16 @@ func AdvertiseAddressFromOptions(opts *options.SAiProxyOptions) (string, error)
|
||||
scheme = "https"
|
||||
}
|
||||
host := strings.TrimSpace(opts.Address)
|
||||
if host == "" || host == "0.0.0.0" {
|
||||
host = "127.0.0.1"
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
ip, err := detectLocalAdvertiseIP()
|
||||
if err != nil || strings.TrimSpace(ip) == "" {
|
||||
log.Warningf("detect local advertise IP failed, fallback to 127.0.0.1: %v", err)
|
||||
host = "127.0.0.1"
|
||||
} else {
|
||||
host = ip
|
||||
}
|
||||
}
|
||||
return normalizeAiProxyNodeAddress(fmt.Sprintf("%s://%s:%d", scheme, host, opts.Port))
|
||||
return normalizeAiProxyNodeAddress(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, strconv.Itoa(opts.Port))))
|
||||
}
|
||||
|
||||
// AccessAddressFromApiServer derives ai_proxy_node.access_address from --api-server.
|
||||
|
||||
@@ -45,7 +45,7 @@ type SAiVirtualKey struct {
|
||||
// OwnerId is the user that owns this virtual key within the project.
|
||||
OwnerId string `width:"128" charset:"ascii" index:"true" list:"user" nullable:"false" create:"optional" update:"user"`
|
||||
// VirtualKey stores the client token encrypted at rest with the row id.
|
||||
VirtualKey string `width:"512" charset:"ascii" nullable:"false" create:"optional" update:"user" get:"user"`
|
||||
VirtualKey string `width:"512" charset:"ascii" nullable:"false" list:"user" create:"optional" update:"user" get:"user"`
|
||||
// VirtualKeyHash is SHA256 of the plaintext token for auth lookup.
|
||||
VirtualKeyHash string `width:"64" charset:"ascii" nullable:"true" unique:"true"`
|
||||
// Limits constrains allowed providers, per-request max_tokens, and request rate.
|
||||
|
||||
@@ -191,7 +191,8 @@ func listProjectRoutingsForVirtualKey(ctx context.Context, userCred mcclient.Tok
|
||||
|
||||
// pickRoutingForRequest chooses the best matching ai_routing on the current aiproxy instance.
|
||||
// Hierarchical refs (routingKey/catalogPart) match only ai_routing.model_key on routeKey.
|
||||
// Flat refs: Phase 1 exact ai_routing.model_key match, Phase 2 ai_routing.model_pattern match.
|
||||
// Flat refs: Phase 1 exact ai_routing.model_key match, Phase 2 non-empty ai_routing.model_pattern match.
|
||||
// Empty model_pattern is not a wildcard; unmatched models return nil (ResolveChatUpstream maps that to ErrNotFound).
|
||||
func pickRoutingForRequest(routings []SAiRouting, reqModel, currentNodeId string) (*SAiRouting, error) {
|
||||
ref := parseClientModelRef(reqModel)
|
||||
if ref.hierarchical {
|
||||
@@ -205,6 +206,9 @@ func pickRoutingForRequest(routings []SAiRouting, reqModel, currentNodeId string
|
||||
return picked, err
|
||||
}
|
||||
return pickRoutingByMatch(routings, reqModel, currentNodeId, func(r *SAiRouting, reqModel string) bool {
|
||||
if strings.TrimSpace(r.ModelPattern) == "" {
|
||||
return false
|
||||
}
|
||||
return modelPatternMatches(r.ModelPattern, reqModel)
|
||||
})
|
||||
}
|
||||
@@ -238,6 +242,33 @@ func pickRoutingByMatch(routings []SAiRouting, reqModel, currentNodeId string, m
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func pickRoutingById(routings []SAiRouting, routingId, currentNodeId string) (*SAiRouting, error) {
|
||||
routingId = strings.TrimSpace(routingId)
|
||||
if routingId == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var boundElsewhere *SAiRouting
|
||||
for i := range routings {
|
||||
r := &routings[i]
|
||||
if r.Id != routingId {
|
||||
continue
|
||||
}
|
||||
if !proxyNodeScopeMatches(r.AiProxyNodeId, currentNodeId) {
|
||||
if strings.TrimSpace(r.AiProxyNodeId) != "" {
|
||||
boundElsewhere = r
|
||||
}
|
||||
continue
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
if boundElsewhere != nil {
|
||||
return nil, errors.Wrapf(httperrors.ErrForbidden,
|
||||
"ai_routing %q is bound to ai_proxy_node %q; use that instance endpoint",
|
||||
boundElsewhere.Name, boundElsewhere.AiProxyNodeId)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type resolvedCatalogModel struct {
|
||||
provider *SAiProvider
|
||||
model *SAiModel
|
||||
@@ -291,10 +322,10 @@ func resolveCatalogModelFromRouting(
|
||||
|
||||
// ResolveChatUpstream resolves upstream URL, API key, and catalog model_key for a chat request:
|
||||
// 1. ai_virtual_key (auth + project scope)
|
||||
// 2. ai_routing in that project (model_key exact match first, then model_pattern / optional proxy-node scope, priority)
|
||||
// 2. optional preferredRoutingId (X-Ai-Routing-Id) pins the ai_routing; otherwise model_key then non-empty model_pattern
|
||||
// 3. ai_routing_model -> ai_provider + ai_model
|
||||
// 4. ai_key rows for that provider matching the catalog model_key (weight)
|
||||
func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential, virtualKey string, body *jsonutils.JSONDict) (*ChatUpstream, error) {
|
||||
func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential, virtualKey string, body *jsonutils.JSONDict, preferredRoutingId string) (*ChatUpstream, error) {
|
||||
vk, err := loadEnabledVirtualKey(virtualKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -309,7 +340,12 @@ func ResolveChatUpstream(ctx context.Context, userCred mcclient.TokenCredential,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routing, err := pickRoutingForRequest(routings, reqModel, CurrentProxyNodeId())
|
||||
var routing *SAiRouting
|
||||
if strings.TrimSpace(preferredRoutingId) != "" {
|
||||
routing, err = pickRoutingById(routings, preferredRoutingId, CurrentProxyNodeId())
|
||||
} else {
|
||||
routing, err = pickRoutingForRequest(routings, reqModel, CurrentProxyNodeId())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -103,11 +103,41 @@ func TestRouterRequestModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRoutingForRequestBoundElsewhereHierarchical(t *testing.T) {
|
||||
routings := []SAiRouting{
|
||||
{Priority: 10, ModelKey: "claude", AiProxyNodeId: "node-b"},
|
||||
func TestPickRoutingByIdHitsPreferred(t *testing.T) {
|
||||
qwen := SAiRouting{ModelKey: "qwen38-Qwen3.8-27B-NVFP4"}
|
||||
qwen.Id = "rid-qwen"
|
||||
qwen.Name = "qwen"
|
||||
ds := SAiRouting{ModelKey: "deepseek-v4-flash"}
|
||||
ds.Id = "rid-ds"
|
||||
ds.Name = "deepseek"
|
||||
routings := []SAiRouting{qwen, ds}
|
||||
|
||||
picked, err := pickRoutingById(routings, "rid-qwen", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
_, err := pickRoutingForRequest(routings, "claude/gpt-4", "node-a")
|
||||
if picked == nil || picked.Id != "rid-qwen" {
|
||||
t.Fatalf("expected pinned qwen routing, got %#v", picked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRoutingByIdNotInList(t *testing.T) {
|
||||
ds := SAiRouting{ModelKey: "deepseek-v4-flash"}
|
||||
ds.Id = "rid-ds"
|
||||
picked, err := pickRoutingById([]SAiRouting{ds}, "rid-missing", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if picked != nil {
|
||||
t.Fatalf("expected nil when routing id is not visible, got %#v", picked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRoutingByIdBoundElsewhere(t *testing.T) {
|
||||
r := SAiRouting{ModelKey: "qwen", AiProxyNodeId: "node-b"}
|
||||
r.Id = "rid-qwen"
|
||||
r.Name = "qwen"
|
||||
_, err := pickRoutingById([]SAiRouting{r}, "rid-qwen", "node-a")
|
||||
if err == nil {
|
||||
t.Fatal("expected forbidden error")
|
||||
}
|
||||
|
||||
@@ -169,6 +169,20 @@ func TestPickRoutingForRequestFallbackPattern(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRoutingForRequestEmptyPatternDoesNotMatch(t *testing.T) {
|
||||
routings := []SAiRouting{
|
||||
{Priority: 10, ModelPattern: ""},
|
||||
{Priority: 20, ModelKey: "deepseek-v4-flash"},
|
||||
}
|
||||
picked, err := pickRoutingForRequest(routings, "qwen38-Qwen3.8-27B-NVFP4", "primary")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if picked != nil {
|
||||
t.Fatalf("empty model_pattern must not catch unmatched models, got %#v", picked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelKeyMatches(t *testing.T) {
|
||||
if !modelKeyMatches("Foo", "foo") {
|
||||
t.Fatal("expected case-insensitive match")
|
||||
|
||||
@@ -22,7 +22,7 @@ type SAiProxyOptions struct {
|
||||
common_options.CommonOptions
|
||||
common_options.DBOptions
|
||||
|
||||
AdvertiseAddress string `help:"Standby node address advertised to clients, e.g. http://10.0.0.2:30889; default derives from bind address and port" default:""`
|
||||
AdvertiseAddress string `help:"Standby node address advertised to clients, e.g. http://10.0.0.2:30889; default derives from bind address or local NIC IP and port" default:""`
|
||||
NodeHeartbeatIntervalSeconds int `help:"Interval in seconds for standby node registration heartbeat" default:"60"`
|
||||
|
||||
APILogEnabled bool `help:"Enable OpenAI API request JSONL logs" default:"true"`
|
||||
|
||||
@@ -215,6 +215,13 @@ type ClouduserSyncInput struct {
|
||||
type ClouduserUpdateInput struct {
|
||||
}
|
||||
|
||||
type ClouduserLoginInfo struct {
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type ClouduserResetPasswordInput struct {
|
||||
// 若此参数为空, 默认会生成随机12位密码
|
||||
//
|
||||
|
||||
@@ -109,7 +109,7 @@ type SClouduser struct {
|
||||
apis.SExternalizedResourceBase
|
||||
SCloudaccountResourceBase
|
||||
SCloudproviderResourceBase
|
||||
Secret string `json:"secret"`
|
||||
Secret string `json:"-"`
|
||||
// 是否可以控制台登录
|
||||
IsConsoleLogin *bool `json:"is_console_login,omitempty"`
|
||||
// 手机号码
|
||||
|
||||
@@ -516,17 +516,26 @@ type ConvertToKvmInput struct {
|
||||
Networks []*NetworkConfig `json:"networks"`
|
||||
|
||||
// dest guest disk storage configs; length must equal guest disks when set
|
||||
// support per-disk backend/storage/medium/schedtags; overrides DiskBackend/PreferStorage/DiskSchedtags
|
||||
// support per-disk backend/storage/medium/schedtags; overrides sys/data disk prefers
|
||||
Disks []*DiskConfig `json:"disks"`
|
||||
|
||||
// Prefer disk backend for all disks, e.g. local/lvm/slvm/nfs/rbd
|
||||
DiskBackend string `json:"disk_backend"`
|
||||
// Prefer disk backend for system disk, e.g. local/lvm/slvm/nfs/rbd
|
||||
SysDiskBackend string `json:"sys_disk_backend"`
|
||||
// Prefer storage id or name for system disk
|
||||
SysPreferStorage string `json:"sys_prefer_storage"`
|
||||
// Prefer medium for system disk, e.g. rotate/ssd/hybrid
|
||||
SysDiskMedium string `json:"sys_disk_medium"`
|
||||
// Prefer disk schedtags for system disk
|
||||
SysDiskSchedtags []*SchedtagConfig `json:"sys_disk_schedtags"`
|
||||
|
||||
// Prefer storage id or name for all disks
|
||||
PreferStorage string `json:"prefer_storage"`
|
||||
|
||||
// Prefer disk schedtags for all disks
|
||||
DiskSchedtags []*SchedtagConfig `json:"disk_schedtags"`
|
||||
// Prefer disk backend for data disks, e.g. local/lvm/slvm/nfs/rbd
|
||||
DataDiskBackend string `json:"data_disk_backend"`
|
||||
// Prefer storage id or name for data disks
|
||||
DataPreferStorage string `json:"data_prefer_storage"`
|
||||
// Prefer medium for data disks, e.g. rotate/ssd/hybrid
|
||||
DataDiskMedium string `json:"data_disk_medium"`
|
||||
// Prefer disk schedtags for data disks
|
||||
DataDiskSchedtags []*SchedtagConfig `json:"data_disk_schedtags"`
|
||||
|
||||
// deploy telegraf after convert
|
||||
DeployTelegraf bool `json:"deploy_telegraf"`
|
||||
|
||||
@@ -23,6 +23,14 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/util/rbacutils"
|
||||
)
|
||||
|
||||
type RolePolicyCreateInput struct {
|
||||
apis.ResourceBaseCreateInput
|
||||
|
||||
RoleId string `json:"role_id"`
|
||||
ProjectId string `json:"project_id"`
|
||||
PolicyId string `json:"policy_id"`
|
||||
}
|
||||
|
||||
type RolePolicyListInput struct {
|
||||
apis.ResourceBaseListInput
|
||||
|
||||
|
||||
@@ -76,33 +76,39 @@ type MCPAgentListInput struct {
|
||||
type MCPAgentCreateInput struct {
|
||||
apis.SharableVirtualResourceCreateInput
|
||||
|
||||
LLMId string `json:"llm_id" help:"LLM 实例 ID,如果提供则自动获取 llm_url"`
|
||||
LLMUrl string `json:"llm_url" help:"后端大模型的 base 请求地址"`
|
||||
LLMDriver string `json:"llm_driver" help:"使用的大模型驱动,可以是 ollama 或 openai"`
|
||||
Model string `json:"model" help:"使用的模型名称"`
|
||||
ApiKey string `json:"api_key" help:"在 llm_driver 中需要用到的认证"`
|
||||
McpServer string `json:"mcp_server" help:"mcp 服务器的后端地址"`
|
||||
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
|
||||
LLMId string `json:"llm_id" help:"deprecated; ignored"`
|
||||
LLMUrl string `json:"llm_url" help:"AI 网关 OpenAI 兼容 base 请求地址"`
|
||||
LLMDriver string `json:"llm_driver" help:"使用的大模型驱动,固定为 openai"`
|
||||
Model string `json:"model" help:"使用的模型名称"`
|
||||
ApiKey string `json:"api_key" help:"deprecated; ignored"`
|
||||
McpServer string `json:"mcp_server" help:"mcp 服务器的后端地址"`
|
||||
AiProxyRoutingId string `json:"aiproxy_routing_id" help:"关联的 AI 网关路由规则 ID"`
|
||||
AiproxyVirtualKeyId string `json:"aiproxy_virtual_key_id" help:"关联的 AI 网关 API Key ID"`
|
||||
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
|
||||
}
|
||||
|
||||
type MCPAgentUpdateInput struct {
|
||||
apis.SharableVirtualResourceCreateInput
|
||||
|
||||
LLMId *string `json:"llm_id,omitempty" help:"LLM 实例 ID,如果提供则自动获取 llm_url"`
|
||||
LLMUrl *string `json:"llm_url,omitempty" help:"后端大模型的 base 请求地址"`
|
||||
LLMDriver *string `json:"llm_driver,omitempty" help:"使用的大模型驱动,可以是 ollama 或 openai"`
|
||||
Model *string `json:"model,omitempty" help:"使用的模型名称"`
|
||||
ApiKey *string `json:"api_key,omitempty" help:"在 llm_driver 中需要用到的认证"`
|
||||
McpServer *string `json:"mcp_server,omitempty" help:"mcp 服务器的后端地址"`
|
||||
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
|
||||
LLMId *string `json:"llm_id,omitempty" help:"deprecated; ignored"`
|
||||
LLMUrl *string `json:"llm_url,omitempty" help:"AI 网关 OpenAI 兼容 base 请求地址"`
|
||||
LLMDriver *string `json:"llm_driver,omitempty" help:"使用的大模型驱动,固定为 openai"`
|
||||
Model *string `json:"model,omitempty" help:"使用的模型名称"`
|
||||
ApiKey *string `json:"api_key,omitempty" help:"deprecated; ignored"`
|
||||
McpServer *string `json:"mcp_server,omitempty" help:"mcp 服务器的后端地址"`
|
||||
AiProxyRoutingId *string `json:"aiproxy_routing_id,omitempty" help:"关联的 AI 网关路由规则 ID"`
|
||||
AiproxyVirtualKeyId *string `json:"aiproxy_virtual_key_id,omitempty" help:"关联的 AI 网关 API Key ID"`
|
||||
DefaultAgent *bool `json:"default_agent,omitempty" help:"set as default MCP agent (only one can be true globally)"`
|
||||
}
|
||||
|
||||
type MCPAgentDetails struct {
|
||||
apis.SharableVirtualResourceDetails
|
||||
|
||||
LLMId string `json:"llm_id"`
|
||||
LLMName string `json:"llm_name"`
|
||||
DefaultAgent bool `json:"default_agent"`
|
||||
LLMId string `json:"llm_id"`
|
||||
LLMName string `json:"llm_name"`
|
||||
AiProxyRoutingId string `json:"aiproxy_routing_id"`
|
||||
AiproxyVirtualKeyId string `json:"aiproxy_virtual_key_id"`
|
||||
DefaultAgent bool `json:"default_agent"`
|
||||
}
|
||||
|
||||
type LLMToolRequestInput struct {
|
||||
|
||||
@@ -71,7 +71,7 @@ type SClouduser struct {
|
||||
SCloudaccountResourceBase
|
||||
SCloudproviderResourceBase
|
||||
|
||||
Secret string `length:"0" charset:"ascii" nullable:"true" list:"user" create:"domain_optional"`
|
||||
Secret string `length:"0" charset:"ascii" nullable:"true" create:"domain_optional" log:"skip"`
|
||||
// 是否可以控制台登录
|
||||
IsConsoleLogin tristate.TriState `default:"false" list:"user" create:"optional"`
|
||||
// 手机号码
|
||||
|
||||
75
pkg/cloudid/models/clouduser_logininfo.go
Normal file
75
pkg/cloudid/models/clouduser_logininfo.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/pkg/errors"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/cloudid"
|
||||
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
)
|
||||
|
||||
func (self *SClouduser) GetDetailsLoginInfo(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (*api.ClouduserLoginInfo, error) {
|
||||
if len(self.Secret) == 0 {
|
||||
return nil, httperrors.NewNotFoundError("No login secret found")
|
||||
}
|
||||
password, err := self.GetPassword()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetPassword")
|
||||
}
|
||||
account, err := self.GetCloudaccount()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetCloudaccount")
|
||||
}
|
||||
return formatClouduserLoginInfo(self.Name, account.Provider, account.IamLoginUrl, password), nil
|
||||
}
|
||||
|
||||
func formatClouduserLoginInfo(name, provider, iamLoginUrl, password string) *api.ClouduserLoginInfo {
|
||||
username := name
|
||||
account := ""
|
||||
switch provider {
|
||||
case computeapi.CLOUD_PROVIDER_ALIYUN:
|
||||
suffix := strings.TrimPrefix(iamLoginUrl, "https://signin.aliyun.com/")
|
||||
suffix = strings.TrimSuffix(suffix, "/login.htm")
|
||||
if len(suffix) > 0 {
|
||||
username = fmt.Sprintf("%s@%s", name, suffix)
|
||||
account = suffix
|
||||
}
|
||||
case computeapi.CLOUD_PROVIDER_QCLOUD, computeapi.CLOUD_PROVIDER_HUAWEI:
|
||||
u, _ := url.Parse(iamLoginUrl)
|
||||
if u != nil {
|
||||
account = u.Query().Get("account")
|
||||
}
|
||||
case computeapi.CLOUD_PROVIDER_AWS:
|
||||
account = strings.TrimPrefix(iamLoginUrl, "https://")
|
||||
if info := strings.Split(account, "."); len(info) > 0 {
|
||||
account = info[0]
|
||||
}
|
||||
}
|
||||
return &api.ClouduserLoginInfo{
|
||||
Account: account,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Url: iamLoginUrl,
|
||||
}
|
||||
}
|
||||
81
pkg/cloudid/models/clouduser_logininfo_test.go
Normal file
81
pkg/cloudid/models/clouduser_logininfo_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/pkg/utils"
|
||||
|
||||
computeapi "yunion.io/x/onecloud/pkg/apis/compute"
|
||||
)
|
||||
|
||||
func TestClouduserSecretFieldTags(t *testing.T) {
|
||||
ft, ok := reflect.TypeOf(SClouduser{}).FieldByName("Secret")
|
||||
if !ok {
|
||||
t.Fatal("Secret field missing")
|
||||
}
|
||||
if got := ft.Tag.Get("list"); got != "" {
|
||||
t.Fatalf("Secret list tag = %q, want empty", got)
|
||||
}
|
||||
if got := ft.Tag.Get("get"); got != "" {
|
||||
t.Fatalf("Secret get tag = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPassword(t *testing.T) {
|
||||
plain := "P@ssw0rd12"
|
||||
user := &SClouduser{}
|
||||
user.Id = "clouduser-id"
|
||||
sec, err := utils.EncryptAESBase64(user.Id, plain)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptAESBase64: %v", err)
|
||||
}
|
||||
user.Secret = sec
|
||||
got, err := user.GetPassword()
|
||||
if err != nil {
|
||||
t.Fatalf("GetPassword: %v", err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Fatalf("GetPassword = %q, want %q", got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatClouduserLoginInfo(t *testing.T) {
|
||||
info := formatClouduserLoginInfo("alice", computeapi.CLOUD_PROVIDER_ALIYUN, "https://signin.aliyun.com/mycorp.onaliyun.com/login.htm", "secret")
|
||||
if info.Username != "alice@mycorp.onaliyun.com" {
|
||||
t.Fatalf("aliyun username = %q", info.Username)
|
||||
}
|
||||
if info.Account != "mycorp.onaliyun.com" {
|
||||
t.Fatalf("aliyun account = %q", info.Account)
|
||||
}
|
||||
if info.Password != "secret" || info.Url == "" {
|
||||
t.Fatalf("unexpected login info: %+v", info)
|
||||
}
|
||||
|
||||
info = formatClouduserLoginInfo("bob", computeapi.CLOUD_PROVIDER_AWS, "https://123456789012.signin.aws.amazon.com/console", "secret")
|
||||
if info.Account != "123456789012" {
|
||||
t.Fatalf("aws account = %q", info.Account)
|
||||
}
|
||||
if info.Username != "bob" {
|
||||
t.Fatalf("aws username = %q", info.Username)
|
||||
}
|
||||
|
||||
info = formatClouduserLoginInfo("carol", computeapi.CLOUD_PROVIDER_QCLOUD, "https://cloud.tencent.com/login?account=corp", "secret")
|
||||
if info.Account != "corp" {
|
||||
t.Fatalf("qcloud account = %q", info.Account)
|
||||
}
|
||||
}
|
||||
@@ -169,72 +169,108 @@ func (self *SGuest) StartConvertToKvmTask(
|
||||
}
|
||||
}
|
||||
|
||||
func isConvertSysDisk(disk *api.DiskConfig) bool {
|
||||
if disk.DiskType == api.DISK_TYPE_SYS {
|
||||
return true
|
||||
}
|
||||
if len(disk.DiskType) > 0 {
|
||||
return false
|
||||
}
|
||||
return disk.Index == 0
|
||||
}
|
||||
|
||||
func fetchPreferStorageId(ctx context.Context, userCred mcclient.TokenCredential, preferStorage string) (string, error) {
|
||||
if len(preferStorage) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
storageObj, err := StorageManager.FetchByIdOrName(ctx, userCred, preferStorage)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return "", httperrors.NewResourceNotFoundError2(StorageManager.Keyword(), preferStorage)
|
||||
}
|
||||
return "", errors.Wrapf(err, "StorageManager.FetchByIdOrName %s", preferStorage)
|
||||
}
|
||||
return storageObj.GetId(), nil
|
||||
}
|
||||
|
||||
type convertDiskPrefer struct {
|
||||
backend string
|
||||
storageId string
|
||||
medium string
|
||||
schedtags []*api.SchedtagConfig
|
||||
}
|
||||
|
||||
func convertDiskTypePrefer(disk *api.DiskConfig, data *api.ConvertToKvmInput, sysStorageId, dataStorageId string) convertDiskPrefer {
|
||||
if isConvertSysDisk(disk) {
|
||||
return convertDiskPrefer{
|
||||
backend: data.SysDiskBackend,
|
||||
storageId: sysStorageId,
|
||||
medium: data.SysDiskMedium,
|
||||
schedtags: data.SysDiskSchedtags,
|
||||
}
|
||||
}
|
||||
return convertDiskPrefer{
|
||||
backend: data.DataDiskBackend,
|
||||
storageId: dataStorageId,
|
||||
medium: data.DataDiskMedium,
|
||||
schedtags: data.DataDiskSchedtags,
|
||||
}
|
||||
}
|
||||
|
||||
// applyConvertDiskConfigs applies target storage preference for convert-to-kvm.
|
||||
// Priority: per-disk Disks configs > DiskBackend / PreferStorage / DiskSchedtags for all disks.
|
||||
// Priority: per-disk Disks configs > sys/data DiskBackend / PreferStorage / Medium / DiskSchedtags.
|
||||
// When nothing is specified, disks keep cleared Backend/Storage (scheduler default, usually local).
|
||||
func applyConvertDiskConfigs(ctx context.Context, userCred mcclient.TokenCredential, disks []*api.DiskConfig, data *api.ConvertToKvmInput) error {
|
||||
if data == nil || len(disks) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
preferStorageId := ""
|
||||
if len(data.PreferStorage) > 0 {
|
||||
storageObj, err := StorageManager.FetchByIdOrName(ctx, userCred, data.PreferStorage)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return httperrors.NewResourceNotFoundError2(StorageManager.Keyword(), data.PreferStorage)
|
||||
}
|
||||
return errors.Wrapf(err, "StorageManager.FetchByIdOrName %s", data.PreferStorage)
|
||||
}
|
||||
preferStorageId = storageObj.GetId()
|
||||
if data.Disks != nil && len(data.Disks) != len(disks) {
|
||||
return httperrors.NewInputParameterError("input disk configs length must equal guest disks length")
|
||||
}
|
||||
|
||||
if data.Disks != nil {
|
||||
if len(data.Disks) != len(disks) {
|
||||
return httperrors.NewInputParameterError("input disk configs length must equal guest disks length")
|
||||
}
|
||||
for i := range disks {
|
||||
if len(data.Disks[i].Backend) > 0 {
|
||||
disks[i].Backend = data.Disks[i].Backend
|
||||
} else if len(data.DiskBackend) > 0 {
|
||||
disks[i].Backend = data.DiskBackend
|
||||
}
|
||||
if len(data.Disks[i].Storage) > 0 {
|
||||
storageObj, err := StorageManager.FetchByIdOrName(ctx, userCred, data.Disks[i].Storage)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return httperrors.NewResourceNotFoundError2(StorageManager.Keyword(), data.Disks[i].Storage)
|
||||
}
|
||||
return errors.Wrapf(err, "StorageManager.FetchByIdOrName %s", data.Disks[i].Storage)
|
||||
}
|
||||
disks[i].Storage = storageObj.GetId()
|
||||
} else if len(preferStorageId) > 0 {
|
||||
disks[i].Storage = preferStorageId
|
||||
}
|
||||
if len(data.Disks[i].Medium) > 0 {
|
||||
disks[i].Medium = data.Disks[i].Medium
|
||||
}
|
||||
if data.Disks[i].Schedtags != nil {
|
||||
disks[i].Schedtags = data.Disks[i].Schedtags
|
||||
} else if data.DiskSchedtags != nil {
|
||||
disks[i].Schedtags = data.DiskSchedtags
|
||||
}
|
||||
}
|
||||
return nil
|
||||
sysPreferStorageId, err := fetchPreferStorageId(ctx, userCred, data.SysPreferStorage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dataPreferStorageId, err := fetchPreferStorageId(ctx, userCred, data.DataPreferStorage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(data.DiskBackend) == 0 && len(preferStorageId) == 0 && data.DiskSchedtags == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range disks {
|
||||
if len(data.DiskBackend) > 0 {
|
||||
disks[i].Backend = data.DiskBackend
|
||||
prefer := convertDiskTypePrefer(disks[i], data, sysPreferStorageId, dataPreferStorageId)
|
||||
var perDisk *api.DiskConfig
|
||||
if data.Disks != nil {
|
||||
perDisk = data.Disks[i]
|
||||
}
|
||||
if len(preferStorageId) > 0 {
|
||||
disks[i].Storage = preferStorageId
|
||||
|
||||
if perDisk != nil && len(perDisk.Backend) > 0 {
|
||||
disks[i].Backend = perDisk.Backend
|
||||
} else if len(prefer.backend) > 0 {
|
||||
disks[i].Backend = prefer.backend
|
||||
}
|
||||
if data.DiskSchedtags != nil {
|
||||
disks[i].Schedtags = data.DiskSchedtags
|
||||
|
||||
if perDisk != nil && len(perDisk.Storage) > 0 {
|
||||
id, err := fetchPreferStorageId(ctx, userCred, perDisk.Storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
disks[i].Storage = id
|
||||
} else if len(prefer.storageId) > 0 {
|
||||
disks[i].Storage = prefer.storageId
|
||||
}
|
||||
|
||||
if perDisk != nil && len(perDisk.Medium) > 0 {
|
||||
disks[i].Medium = perDisk.Medium
|
||||
} else if len(prefer.medium) > 0 {
|
||||
disks[i].Medium = prefer.medium
|
||||
}
|
||||
|
||||
if perDisk != nil && perDisk.Schedtags != nil {
|
||||
disks[i].Schedtags = perDisk.Schedtags
|
||||
} else if prefer.schedtags != nil {
|
||||
disks[i].Schedtags = prefer.schedtags
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -242,6 +242,7 @@ func getGuestConfig(
|
||||
osName = "Linux"
|
||||
}
|
||||
|
||||
// append default route
|
||||
if conf.Gateway != nil {
|
||||
if !strings.HasPrefix(strings.ToLower(osName), "win") {
|
||||
route4 = append(route4, netutils2.SRouteInfo{
|
||||
@@ -264,6 +265,18 @@ func getGuestConfig(
|
||||
})*/
|
||||
//}
|
||||
}
|
||||
// append link-local route
|
||||
if conf.ServerIP != nil {
|
||||
route4 = append(route4, netutils2.SRouteInfo{
|
||||
SPrefixInfo: netutils2.SPrefixInfo{
|
||||
Prefix: conf.ServerIP,
|
||||
PrefixLen: uint8(nicdesc.Masklen),
|
||||
},
|
||||
// link-local route gateway IP is the nic IP
|
||||
Gateway: net.ParseIP("0.0.0.0"),
|
||||
})
|
||||
}
|
||||
|
||||
route4, route6 = netutils2.AddNicRoutes(route4, route6, nicdesc, mainIp, mainIp6, len(guestNics))
|
||||
|
||||
conf.Routes = route4
|
||||
|
||||
@@ -569,6 +569,9 @@ func (self *SImage) SaveImageFromStream(reader io.Reader, totalSize int64, calCh
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "NewQemuImage %s", localPath)
|
||||
}
|
||||
if err := img.CheckNoBackingFile(); err != nil {
|
||||
return err
|
||||
}
|
||||
format = string(img.String2ImageFormat())
|
||||
virtualSizeBytes = img.SizeBytes
|
||||
|
||||
@@ -1217,7 +1220,14 @@ func (self *SImage) GetNewLocation(newLocalPath string) string {
|
||||
}
|
||||
|
||||
func (self *SImage) getQemuImage() (*qemuimg.SQemuImage, error) {
|
||||
return qemuimg.NewQemuImageWithIOLevel(self.GetLocalLocation(), qemuimg.IONiceIdle)
|
||||
img, err := qemuimg.NewQemuImageWithIOLevel(self.GetLocalLocation(), qemuimg.IONiceIdle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := img.CheckNoBackingFile(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (self *SImage) StopTorrents() {
|
||||
@@ -1775,6 +1785,13 @@ func (image *SImage) doProbeImageInfo(ctx context.Context, userCred mcclient.Tok
|
||||
if len(diskPath) == 0 {
|
||||
return false, errors.Wrap(httperrors.ErrNotFound, "disk file not found")
|
||||
}
|
||||
qimg, err := qemuimg.NewQemuImage(diskPath)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "NewQemuImage")
|
||||
}
|
||||
if err := qimg.CheckNoBackingFile(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if deployclient.GetDeployClient() == nil {
|
||||
return false, fmt.Errorf("deploy client not init")
|
||||
}
|
||||
@@ -2231,6 +2248,17 @@ func (img *SImage) cacheToCephStorages(ctx context.Context) {
|
||||
}
|
||||
if cachedRbdimgStorageId == "" {
|
||||
// do cache img to ceph storage
|
||||
if fileutils2.Exists(localPath) {
|
||||
qimg, err := qemuimg.NewQemuImage(localPath)
|
||||
if err != nil {
|
||||
log.Errorf("skip cache img %s: NewQemuImage %s", img.Id, err)
|
||||
return
|
||||
}
|
||||
if err := qimg.CheckNoBackingFile(); err != nil {
|
||||
log.Errorf("skip cache img %s: %s", img.Id, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
for storageId := range storageCachedImages {
|
||||
storageConf := cephStorages.StorageIdConf[storageId]
|
||||
imgTmpName := "image_cache_" + img.Id + ".tmp"
|
||||
|
||||
@@ -49,8 +49,7 @@ func ValidateConfig(ctx context.Context, conf api.SIdpAttributeOptions, userCred
|
||||
}
|
||||
conf.DefaultRoleId = obj.GetId()
|
||||
}
|
||||
if len(conf.DefaultProjectId) > 0 && len(conf.DefaultRoleId) > 0 {
|
||||
// validate policy
|
||||
if len(conf.DefaultRoleId) > 0 {
|
||||
err := models.ValidateJoinProjectRoles(userCred, conf.DefaultProjectId, []string{conf.DefaultRoleId})
|
||||
if err != nil {
|
||||
return conf, errors.Wrap(err, "ValidateJoinProjectRoles")
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
api "yunion.io/x/onecloud/pkg/apis/identity"
|
||||
"yunion.io/x/onecloud/pkg/appsrv"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/policy"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/keystone/options"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
@@ -584,6 +585,17 @@ func AddAdhocHandlers(version string, app *appsrv.Application) {
|
||||
}
|
||||
|
||||
func roleAssignmentHandler(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
userCred := auth.FetchUserCredential(ctx, policy.FilterPolicyCredential)
|
||||
if userCred == nil {
|
||||
httperrors.UnauthorizedError(ctx, w, "unauthorized")
|
||||
return
|
||||
}
|
||||
allowScope, policyResult := policy.PolicyManager.AllowScope(userCred, api.SERVICE_TYPE, "role_assignments", policy.PolicyActionList)
|
||||
if policyResult.Result.IsDeny() {
|
||||
httperrors.ForbiddenError(ctx, w, "not allow to list role assignments")
|
||||
return
|
||||
}
|
||||
|
||||
_, query, _ := appsrv.FetchEnv(ctx, w, r)
|
||||
input := api.RoleAssignmentsInput{}
|
||||
err := query.Unmarshal(&input)
|
||||
@@ -592,6 +604,12 @@ func roleAssignmentHandler(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
return
|
||||
}
|
||||
|
||||
restrictDomainId, err := checkRoleAssignmentListInput(allowScope, userCred, &input)
|
||||
if err != nil {
|
||||
httperrors.GeneralServerError(ctx, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
includeNames := (input.IncludeNames != nil)
|
||||
effective := (input.Effective != nil)
|
||||
includeSub := (input.IncludeSubtree != nil)
|
||||
@@ -607,7 +625,7 @@ func roleAssignmentHandler(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
offset = *input.Offset
|
||||
}
|
||||
|
||||
results, total, err := AssignmentManager.FetchAll(
|
||||
results, total, err := AssignmentManager.fetchAll(
|
||||
input.User.Id,
|
||||
input.Group.Id,
|
||||
input.Role.Id,
|
||||
@@ -620,6 +638,7 @@ func roleAssignmentHandler(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
input.Domains,
|
||||
input.Projects,
|
||||
input.ProjectDomains,
|
||||
restrictDomainId,
|
||||
includeNames, effective, includeSub, includeSystem, includePolicies,
|
||||
limit, offset)
|
||||
|
||||
@@ -639,6 +658,7 @@ func roleAssignmentHandler(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
func (manager *SAssignmentManager) queryAll(
|
||||
userId, groupId, roleId, domainId, projectId string, projectDomainId string,
|
||||
users, groups, roles, domains, projects, projectDomains []string,
|
||||
restrictDomainId string,
|
||||
) *sqlchemy.SQuery {
|
||||
assigments := manager.Query().SubQuery()
|
||||
q := assigments.Query(
|
||||
@@ -749,6 +769,13 @@ func (manager *SAssignmentManager) queryAll(
|
||||
))
|
||||
q = q.In("domain_id", subq.SubQuery()).In("type", []string{api.AssignmentUserDomain, api.AssignmentGroupDomain})
|
||||
}
|
||||
if len(restrictDomainId) > 0 {
|
||||
projSubq := ProjectManager.Query("id").Equals("domain_id", restrictDomainId).SubQuery()
|
||||
q = q.Filter(sqlchemy.OR(
|
||||
sqlchemy.In(q.Field("project_id"), projSubq),
|
||||
sqlchemy.Equals(q.Field("domain_id"), restrictDomainId),
|
||||
))
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
@@ -807,13 +834,27 @@ func (manager *SAssignmentManager) FetchAll(
|
||||
userStrs, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs []string,
|
||||
includeNames, effective, includeSub, includeSystem, includePolicies bool,
|
||||
limit, offset int) ([]api.SRoleAssignment, int64, error) {
|
||||
return manager.fetchAll(
|
||||
userId, groupId, roleId, domainId, projectId, projectDomainId,
|
||||
userStrs, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs,
|
||||
"",
|
||||
includeNames, effective, includeSub, includeSystem, includePolicies,
|
||||
limit, offset)
|
||||
}
|
||||
|
||||
func (manager *SAssignmentManager) fetchAll(
|
||||
userId, groupId, roleId, domainId, projectId string, projectDomainId string,
|
||||
userStrs, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs []string,
|
||||
restrictDomainId string,
|
||||
includeNames, effective, includeSub, includeSystem, includePolicies bool,
|
||||
limit, offset int) ([]api.SRoleAssignment, int64, error) {
|
||||
var q *sqlchemy.SQuery
|
||||
if effective {
|
||||
usrq := manager.queryAll(userId, "", roleId, domainId, projectId, projectDomainId, userStrs, nil, roleStrs, domainStrs, projectStrs, projectDomainStrs).In("type", []string{api.AssignmentUserProject, api.AssignmentUserDomain})
|
||||
usrq := manager.queryAll(userId, "", roleId, domainId, projectId, projectDomainId, userStrs, nil, roleStrs, domainStrs, projectStrs, projectDomainStrs, restrictDomainId).In("type", []string{api.AssignmentUserProject, api.AssignmentUserDomain})
|
||||
|
||||
memberships := UsergroupManager.Query("user_id", "group_id").SubQuery()
|
||||
|
||||
grpproj := manager.queryAll("", groupId, roleId, domainId, projectId, projectDomainId, nil, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs).In("type", []string{api.AssignmentGroupProject, api.AssignmentGroupDomain}).SubQuery()
|
||||
grpproj := manager.queryAll("", groupId, roleId, domainId, projectId, projectDomainId, nil, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs, restrictDomainId).In("type", []string{api.AssignmentGroupProject, api.AssignmentGroupDomain}).SubQuery()
|
||||
q2 := grpproj.Query(
|
||||
grpproj.Field("type"),
|
||||
memberships.Field("user_id"),
|
||||
@@ -837,7 +878,7 @@ func (manager *SAssignmentManager) FetchAll(
|
||||
|
||||
q = sqlchemy.Union(usrq, q2).Query().Distinct()
|
||||
} else {
|
||||
q = manager.queryAll(userId, groupId, roleId, domainId, projectId, projectDomainId, userStrs, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs).Distinct()
|
||||
q = manager.queryAll(userId, groupId, roleId, domainId, projectId, projectDomainId, userStrs, groupStrs, roleStrs, domainStrs, projectStrs, projectDomainStrs, restrictDomainId).Distinct()
|
||||
}
|
||||
|
||||
if !includeSystem {
|
||||
|
||||
67
pkg/keystone/models/assignments_scope.go
Normal file
67
pkg/keystone/models/assignments_scope.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/identity"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
)
|
||||
|
||||
func checkRoleAssignmentListInput(allowScope rbacscope.TRbacScope, userCred mcclient.TokenCredential, input *api.RoleAssignmentsInput) (string, error) {
|
||||
switch allowScope {
|
||||
case rbacscope.ScopeSystem:
|
||||
return "", nil
|
||||
case rbacscope.ScopeDomain:
|
||||
domainId := userCred.GetProjectDomainId()
|
||||
if err := rejectRoleAssignmentMismatch(input.ProjectDomainId, domainId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := rejectRoleAssignmentMismatch(input.Scope.Domain.Id, domainId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return domainId, nil
|
||||
case rbacscope.ScopeProject:
|
||||
projectId := userCred.GetProjectId()
|
||||
if err := rejectRoleAssignmentMismatch(input.Scope.Project.Id, projectId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
input.Scope.Project.Id = projectId
|
||||
input.IncludePolicies = nil
|
||||
return "", nil
|
||||
case rbacscope.ScopeUser:
|
||||
userId := userCred.GetUserId()
|
||||
if err := rejectRoleAssignmentMismatch(input.User.Id, userId); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(input.Group.Id) > 0 || len(input.Groups) > 0 {
|
||||
return "", httperrors.NewForbiddenError("not allow to list role assignments")
|
||||
}
|
||||
input.User.Id = userId
|
||||
input.IncludePolicies = nil
|
||||
return "", nil
|
||||
default:
|
||||
return "", httperrors.NewForbiddenError("not allow to list role assignments")
|
||||
}
|
||||
}
|
||||
|
||||
func rejectRoleAssignmentMismatch(requested, allowed string) error {
|
||||
if len(requested) > 0 && requested != allowed {
|
||||
return httperrors.NewForbiddenError("not allow to list role assignments")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
120
pkg/keystone/models/assignments_scope_test.go
Normal file
120
pkg/keystone/models/assignments_scope_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/identity"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
)
|
||||
|
||||
func testRoleAssignmentCred() *mcclient.SSimpleToken {
|
||||
return &mcclient.SSimpleToken{
|
||||
UserId: "user-1",
|
||||
ProjectId: "proj-1",
|
||||
ProjectDomainId: "domain-1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRoleAssignmentListInputSystem(t *testing.T) {
|
||||
input := api.RoleAssignmentsInput{}
|
||||
input.User.Id = "other-user"
|
||||
restrict, err := checkRoleAssignmentListInput(rbacscope.ScopeSystem, testRoleAssignmentCred(), &input)
|
||||
if err != nil {
|
||||
t.Fatalf("system scope: %v", err)
|
||||
}
|
||||
if restrict != "" {
|
||||
t.Fatalf("system restrictDomainId = %q", restrict)
|
||||
}
|
||||
if input.User.Id != "other-user" {
|
||||
t.Fatalf("system must not rewrite user id, got %q", input.User.Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRoleAssignmentListInputUser(t *testing.T) {
|
||||
cred := testRoleAssignmentCred()
|
||||
input := api.RoleAssignmentsInput{}
|
||||
restrict, err := checkRoleAssignmentListInput(rbacscope.ScopeUser, cred, &input)
|
||||
if err != nil {
|
||||
t.Fatalf("user scope: %v", err)
|
||||
}
|
||||
if restrict != "" {
|
||||
t.Fatalf("user restrictDomainId = %q", restrict)
|
||||
}
|
||||
if input.User.Id != cred.UserId {
|
||||
t.Fatalf("user id = %q, want %q", input.User.Id, cred.UserId)
|
||||
}
|
||||
if input.IncludePolicies != nil {
|
||||
t.Fatal("user scope must not request include_policies")
|
||||
}
|
||||
|
||||
input.User.Id = "other-user"
|
||||
if _, err := checkRoleAssignmentListInput(rbacscope.ScopeUser, cred, &input); err == nil {
|
||||
t.Fatal("expected error when listing another user")
|
||||
}
|
||||
|
||||
input = api.RoleAssignmentsInput{}
|
||||
input.Group.Id = "group-1"
|
||||
if _, err := checkRoleAssignmentListInput(rbacscope.ScopeUser, cred, &input); err == nil {
|
||||
t.Fatal("expected error when listing by group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRoleAssignmentListInputDomain(t *testing.T) {
|
||||
cred := testRoleAssignmentCred()
|
||||
input := api.RoleAssignmentsInput{}
|
||||
restrict, err := checkRoleAssignmentListInput(rbacscope.ScopeDomain, cred, &input)
|
||||
if err != nil {
|
||||
t.Fatalf("domain scope: %v", err)
|
||||
}
|
||||
if restrict != cred.ProjectDomainId {
|
||||
t.Fatalf("restrictDomainId = %q, want %q", restrict, cred.ProjectDomainId)
|
||||
}
|
||||
|
||||
input.ProjectDomainId = "other-domain"
|
||||
if _, err := checkRoleAssignmentListInput(rbacscope.ScopeDomain, cred, &input); err == nil {
|
||||
t.Fatal("expected error when listing another domain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRoleAssignmentListInputProject(t *testing.T) {
|
||||
cred := testRoleAssignmentCred()
|
||||
input := api.RoleAssignmentsInput{}
|
||||
restrict, err := checkRoleAssignmentListInput(rbacscope.ScopeProject, cred, &input)
|
||||
if err != nil {
|
||||
t.Fatalf("project scope: %v", err)
|
||||
}
|
||||
if restrict != "" {
|
||||
t.Fatalf("project restrictDomainId = %q", restrict)
|
||||
}
|
||||
if input.Scope.Project.Id != cred.ProjectId {
|
||||
t.Fatalf("project id = %q, want %q", input.Scope.Project.Id, cred.ProjectId)
|
||||
}
|
||||
|
||||
input.Scope.Project.Id = "other-proj"
|
||||
if _, err := checkRoleAssignmentListInput(rbacscope.ScopeProject, cred, &input); err == nil {
|
||||
t.Fatal("expected error when listing another project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRoleAssignmentListInputNone(t *testing.T) {
|
||||
input := api.RoleAssignmentsInput{}
|
||||
if _, err := checkRoleAssignmentListInput(rbacscope.ScopeNone, testRoleAssignmentCred(), &input); err == nil {
|
||||
t.Fatal("expected error when scope is none")
|
||||
}
|
||||
}
|
||||
@@ -1303,6 +1303,7 @@ func (idp *SIdentityProvider) TryUserJoinProject(attrConf api.SIdpAttributeOptio
|
||||
}
|
||||
|
||||
var targetProject *SProject
|
||||
projectFromAttr := false
|
||||
log.Debugf("userTryJoinProject resp %s proj %s", attrs, attrConf.ProjectAttribute)
|
||||
if !consts.GetNonDefaultDomainProjects() {
|
||||
// if non-default-domain-project is disabled, place new project in default domain
|
||||
@@ -1325,6 +1326,9 @@ func (idp *SIdentityProvider) TryUserJoinProject(attrConf api.SIdpAttributeOptio
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetProject != nil {
|
||||
projectFromAttr = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetProject == nil && len(attrConf.DefaultProjectId) > 0 {
|
||||
@@ -1343,6 +1347,8 @@ func (idp *SIdentityProvider) TryUserJoinProject(attrConf api.SIdpAttributeOptio
|
||||
targetRole, err := RoleManager.FetchRole("", roleName, domainId, "")
|
||||
if err != nil {
|
||||
log.Errorf("fetch role %s fail %s", roleName, err)
|
||||
} else if err := validateIdpJoinRole(targetProject, targetRole, idpJoinAllowsSystemRole(projectFromAttr, true)); err != nil {
|
||||
log.Errorf("skip role %s for idp %s: %s", roleName, idp.Name, err)
|
||||
} else {
|
||||
targetRoles = append(targetRoles, targetRole)
|
||||
}
|
||||
@@ -1353,6 +1359,8 @@ func (idp *SIdentityProvider) TryUserJoinProject(attrConf api.SIdpAttributeOptio
|
||||
targetRole, err := RoleManager.FetchRoleById(attrConf.DefaultRoleId)
|
||||
if err != nil {
|
||||
log.Errorf("fetch default role %s fail %s", attrConf.DefaultRoleId, err)
|
||||
} else if err := validateIdpJoinRole(targetProject, targetRole, idpJoinAllowsSystemRole(projectFromAttr, false)); err != nil {
|
||||
log.Errorf("skip default role %s for idp %s: %s", targetRole.Name, idp.Name, err)
|
||||
} else {
|
||||
targetRoles = append(targetRoles, targetRole)
|
||||
}
|
||||
|
||||
52
pkg/keystone/models/idp_join.go
Normal file
52
pkg/keystone/models/idp_join.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"yunion.io/x/pkg/errors"
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/keystone/options"
|
||||
"yunion.io/x/onecloud/pkg/util/rbacutils"
|
||||
)
|
||||
|
||||
// idpJoinAllowsSystemRole is true only when both project and role come from IdP configuration defaults.
|
||||
func idpJoinAllowsSystemRole(projectFromAttr, roleFromAttr bool) bool {
|
||||
return !projectFromAttr && !roleFromAttr
|
||||
}
|
||||
|
||||
func validateIdpJoinPolicies(assignPolicies rbacutils.TPolicyGroup, allowSystem bool) error {
|
||||
if !allowSystem && assignPolicies.HighestScope() == rbacscope.ScopeSystem {
|
||||
return errors.Wrap(httperrors.ErrNotSufficientPrivilege, "assigning roles requires higher privilege scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateIdpJoinRole(project *SProject, role *SRole, allowSystem bool) error {
|
||||
_, assignPolicies, err := RolePolicyManager.GetMatchPolicyGroup2(false, []string{role.Id}, project.Id, "", time.Time{}, false)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetMatchPolicyGroup2")
|
||||
}
|
||||
if err := validateIdpJoinPolicies(assignPolicies, allowSystem); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.Options.ThreeAdminRoleSystem {
|
||||
return threeMemberSystemValidatePolicies(GetDefaultAdminCred(), project.Id, assignPolicies)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
60
pkg/keystone/models/idp_join_test.go
Normal file
60
pkg/keystone/models/idp_join_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/pkg/util/rbacscope"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/util/rbacutils"
|
||||
)
|
||||
|
||||
func TestIdpJoinAllowsSystemRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
projectFromAttr bool
|
||||
roleFromAttr bool
|
||||
want bool
|
||||
}{
|
||||
{false, false, true},
|
||||
{true, false, false},
|
||||
{false, true, false},
|
||||
{true, true, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := idpJoinAllowsSystemRole(c.projectFromAttr, c.roleFromAttr)
|
||||
if got != c.want {
|
||||
t.Fatalf("projectFromAttr=%v roleFromAttr=%v got %v want %v", c.projectFromAttr, c.roleFromAttr, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdpJoinPolicies(t *testing.T) {
|
||||
sys := rbacutils.TPolicyGroup{rbacscope.ScopeSystem: {}}
|
||||
if err := validateIdpJoinPolicies(sys, false); err == nil {
|
||||
t.Fatal("expected error for system-scope role from attributes")
|
||||
}
|
||||
if err := validateIdpJoinPolicies(sys, true); err != nil {
|
||||
t.Fatalf("configured default may assign system-scope role: %v", err)
|
||||
}
|
||||
proj := rbacutils.TPolicyGroup{rbacscope.ScopeProject: {}}
|
||||
if err := validateIdpJoinPolicies(proj, false); err != nil {
|
||||
t.Fatalf("project-scope role should be allowed: %v", err)
|
||||
}
|
||||
domain := rbacutils.TPolicyGroup{rbacscope.ScopeDomain: {}}
|
||||
if err := validateIdpJoinPolicies(domain, false); err != nil {
|
||||
t.Fatalf("domain-scope role should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -797,7 +797,6 @@ func (policy *SPolicy) fetchMatchableRoles() ([]SRole, error) {
|
||||
|
||||
// 绑定角色
|
||||
func (policy *SPolicy) PerformBindRole(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input api.PolicyBindRoleInput) (jsonutils.JSONObject, error) {
|
||||
var projectId string
|
||||
prefList := make([]netutils.IPV4Prefix, 0)
|
||||
for _, ipStr := range input.Ips {
|
||||
pref, err := netutils.NewIPV4Prefix(ipStr)
|
||||
@@ -806,29 +805,11 @@ func (policy *SPolicy) PerformBindRole(ctx context.Context, userCred mcclient.To
|
||||
}
|
||||
prefList = append(prefList, pref)
|
||||
}
|
||||
if len(input.ProjectId) > 0 {
|
||||
proj, err := ProjectManager.FetchByIdOrName(ctx, userCred, input.ProjectId)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return nil, errors.Wrapf(httperrors.ErrNotFound, "%s %s", ProjectManager.Keyword(), input.ProjectId)
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "ProjectManager.FetchByIdOrName")
|
||||
}
|
||||
}
|
||||
projectId = proj.GetId()
|
||||
}
|
||||
if len(input.RoleId) == 0 {
|
||||
return nil, errors.Wrap(httperrors.ErrInputParameter, "missing role_id")
|
||||
}
|
||||
role, err := RoleManager.FetchByIdOrName(ctx, userCred, input.RoleId)
|
||||
roleId, projectId, policyId, err := normalizeRolePolicyBinding(ctx, userCred, input.RoleId, input.ProjectId, policy.Id)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return nil, errors.Wrapf(httperrors.ErrNotFound, "%s %s", RoleManager.Keyword(), input.RoleId)
|
||||
} else {
|
||||
return nil, errors.Wrap(err, "RoleManager.FetchByIdOrName")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
err = RolePolicyManager.newRecord(ctx, role.GetId(), projectId, policy.Id, tristate.True, prefList, input.ValidSince, input.ValidUntil)
|
||||
err = RolePolicyManager.newRecord(ctx, roleId, projectId, policyId, tristate.True, prefList, input.ValidSince, input.ValidUntil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "newRecord")
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/identity"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
policyman "yunion.io/x/onecloud/pkg/cloudcommon/policy"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/keystone/options"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
@@ -62,7 +63,7 @@ type SRolePolicy struct {
|
||||
db.SResourceBase
|
||||
|
||||
// 角色ID, 主键
|
||||
RoleId string `width:"128" charset:"ascii" primary:"true" list:"domain" create:"domain_optional"`
|
||||
RoleId string `width:"128" charset:"ascii" primary:"true" list:"domain" create:"domain_required"`
|
||||
// 项目ID,主键
|
||||
ProjectId string `width:"128" charset:"ascii" primary:"true" list:"domain" create:"domain_optional"`
|
||||
// 权限ID, 主键
|
||||
@@ -105,6 +106,95 @@ func (manager *SRolePolicyManager) newRecord(ctx context.Context, roleId, projec
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeRolePolicyBinding(ctx context.Context, userCred mcclient.TokenCredential, roleId, projectId, policyId string) (string, string, string, error) {
|
||||
roleId = strings.TrimSpace(roleId)
|
||||
projectId = strings.TrimSpace(projectId)
|
||||
policyId = strings.TrimSpace(policyId)
|
||||
if roleId == "" {
|
||||
return "", "", "", httperrors.NewMissingParameterError("role_id")
|
||||
}
|
||||
if policyId == "" {
|
||||
return "", "", "", httperrors.NewMissingParameterError("policy_id")
|
||||
}
|
||||
|
||||
roleObj, err := RoleManager.FetchByIdOrName(ctx, userCred, roleId)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return "", "", "", httperrors.NewResourceNotFoundError2(RoleManager.Keyword(), roleId)
|
||||
}
|
||||
return "", "", "", errors.Wrap(err, "RoleManager.FetchByIdOrName")
|
||||
}
|
||||
role := roleObj.(*SRole)
|
||||
|
||||
policyObj, err := PolicyManager.FetchByIdOrName(ctx, userCred, policyId)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return "", "", "", httperrors.NewResourceNotFoundError2(PolicyManager.Keyword(), policyId)
|
||||
}
|
||||
return "", "", "", errors.Wrap(err, "PolicyManager.FetchByIdOrName")
|
||||
}
|
||||
pol := policyObj.(*SPolicy)
|
||||
|
||||
if projectId != "" {
|
||||
projObj, err := ProjectManager.FetchByIdOrName(ctx, userCred, projectId)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == sql.ErrNoRows {
|
||||
return "", "", "", httperrors.NewResourceNotFoundError2(ProjectManager.Keyword(), projectId)
|
||||
}
|
||||
return "", "", "", errors.Wrap(err, "ProjectManager.FetchByIdOrName")
|
||||
}
|
||||
projectId = projObj.GetId()
|
||||
}
|
||||
|
||||
isBootStrap, err := RolePolicyManager.isBootstrapRolePolicy()
|
||||
if err != nil {
|
||||
return "", "", "", errors.Wrap(err, "isBootstrapRolePolicy")
|
||||
}
|
||||
if !isBootStrap {
|
||||
if err := db.IsObjectRbacAllowed(ctx, role, userCred, policyman.PolicyActionPerform, "add-policy"); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := db.IsObjectRbacAllowed(ctx, pol, userCred, policyman.PolicyActionPerform, "bind-role"); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
rps, err := RolePolicyManager.fetchByRoleId(role.Id)
|
||||
if err != nil {
|
||||
return "", "", "", errors.Wrap(err, "fetchByRoleId")
|
||||
}
|
||||
policyIds := stringutils2.NewSortedStrings(nil)
|
||||
for i := range rps {
|
||||
policyIds = stringutils2.Append(policyIds, rps[i].PolicyId)
|
||||
}
|
||||
policyIds = stringutils2.Append(policyIds, pol.Id)
|
||||
if err := validateRolePolicies(userCred, policyIds); err != nil {
|
||||
return "", "", "", errors.Wrap(err, "validateRolePolicies")
|
||||
}
|
||||
}
|
||||
return role.Id, projectId, pol.Id, nil
|
||||
}
|
||||
|
||||
func (manager *SRolePolicyManager) ValidateCreateData(
|
||||
ctx context.Context,
|
||||
userCred mcclient.TokenCredential,
|
||||
ownerId mcclient.IIdentityProvider,
|
||||
query jsonutils.JSONObject,
|
||||
input api.RolePolicyCreateInput,
|
||||
) (api.RolePolicyCreateInput, error) {
|
||||
var err error
|
||||
input.ResourceBaseCreateInput, err = manager.SResourceBaseManager.ValidateCreateData(ctx, userCred, ownerId, query, input.ResourceBaseCreateInput)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "SResourceBaseManager.ValidateCreateData")
|
||||
}
|
||||
roleId, projectId, policyId, err := normalizeRolePolicyBinding(ctx, userCred, input.RoleId, input.ProjectId, input.PolicyId)
|
||||
if err != nil {
|
||||
return input, err
|
||||
}
|
||||
input.RoleId = roleId
|
||||
input.ProjectId = projectId
|
||||
input.PolicyId = policyId
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (manager *SRolePolicyManager) deleteRecord(ctx context.Context, roleId, projectId, policyId string) error {
|
||||
rpg := SRolePolicy{}
|
||||
rpg.RoleId = roleId
|
||||
@@ -360,14 +450,12 @@ func (manager *SRolePolicyManager) getMatchPolicyIds(userCred rbacutils.IRbacIde
|
||||
}
|
||||
|
||||
func (manager *SRolePolicyManager) getMatchPolicyIds2(isGuest bool, roleIds []string, pid string, loginIp string, tm time.Time) ([]string, error) {
|
||||
if !isGuest && len(roleIds) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
q := manager.Query()
|
||||
if !isGuest {
|
||||
if len(roleIds) > 0 {
|
||||
q = q.Filter(sqlchemy.OR(
|
||||
sqlchemy.IsNullOrEmpty(q.Field("role_id")),
|
||||
sqlchemy.In(q.Field("role_id"), roleIds),
|
||||
))
|
||||
}
|
||||
q = q.In("role_id", roleIds)
|
||||
if len(pid) > 0 {
|
||||
q = q.Filter(sqlchemy.OR(
|
||||
sqlchemy.IsNullOrEmpty(q.Field("project_id")),
|
||||
|
||||
54
pkg/keystone/models/rolepolicies_test.go
Normal file
54
pkg/keystone/models/rolepolicies_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
)
|
||||
|
||||
func TestNormalizeRolePolicyBindingRequiresRoleAndPolicy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
userCred := &mcclient.SSimpleToken{}
|
||||
_, _, _, err := normalizeRolePolicyBinding(ctx, userCred, "", "project-1", "policy-1")
|
||||
if err == nil {
|
||||
t.Fatal("empty role_id: expected error")
|
||||
}
|
||||
_, _, _, err = normalizeRolePolicyBinding(ctx, userCred, " ", "project-1", "policy-1")
|
||||
if err == nil {
|
||||
t.Fatal("whitespace role_id: expected error")
|
||||
}
|
||||
_, _, _, err = normalizeRolePolicyBinding(ctx, userCred, "role-1", "project-1", "")
|
||||
if err == nil {
|
||||
t.Fatal("empty policy_id: expected error")
|
||||
}
|
||||
_, _, _, err = normalizeRolePolicyBinding(ctx, userCred, "role-1", "project-1", " ")
|
||||
if err == nil {
|
||||
t.Fatal("whitespace policy_id: expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMatchPolicyIds2NoRoles(t *testing.T) {
|
||||
ids, err := RolePolicyManager.getMatchPolicyIds2(false, nil, "project-1", "", time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ids) != 0 {
|
||||
t.Fatalf("got %v, want empty", ids)
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,12 @@ var (
|
||||
Action: PolicyActionDelete,
|
||||
Result: rbacutils.Allow,
|
||||
},
|
||||
{
|
||||
Service: api.SERVICE_TYPE,
|
||||
Resource: "role_assignments",
|
||||
Action: PolicyActionList,
|
||||
Result: rbacutils.Allow,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -35,6 +35,7 @@ var (
|
||||
}
|
||||
identityUserResources = []string{
|
||||
"credentials",
|
||||
"role_assignments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -55,18 +55,6 @@ func authUserByAssume(ctx context.Context, input mcclient.SAuthenticationInputV3
|
||||
return nil, errors.Wrap(err, "fetch scoped project")
|
||||
}
|
||||
|
||||
var requireScope rbacscope.TRbacScope
|
||||
if adminToken.ProjectId == scopedProject.Id {
|
||||
requireScope = rbacscope.ScopeProject
|
||||
} else if adminToken.DomainId == scopedProject.DomainId {
|
||||
requireScope = rbacscope.ScopeDomain
|
||||
} else {
|
||||
requireScope = rbacscope.ScopeSystem
|
||||
}
|
||||
|
||||
adminToken.ProjectId = scopedProject.Id
|
||||
adminToken.DomainId = scopedProject.DomainId
|
||||
|
||||
adminTokenCred, err := adminToken.GetSimpleUserCred(input.Auth.Identity.Token.Id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get admin token credential")
|
||||
@@ -90,8 +78,8 @@ func authUserByAssume(ctx context.Context, input mcclient.SAuthenticationInputV3
|
||||
}
|
||||
|
||||
if adminTokenCred.GetUserId() != targetUser.Id {
|
||||
if policy.PolicyManager.Allow(requireScope, adminTokenCred, api.SERVICE_TYPE, "tokens", "perform", "assume").Result.IsDeny() {
|
||||
return nil, httperrors.NewForbiddenError("%s not allow to assume user in project %s", adminTokenCred.GetUserName(), scopedProject.Name)
|
||||
if err := checkAssumeAllowed(adminTokenCred, targetUser.Id, scopedProject); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,3 +88,21 @@ func authUserByAssume(ctx context.Context, input mcclient.SAuthenticationInputV3
|
||||
|
||||
return targetUser, nil
|
||||
}
|
||||
|
||||
func checkAssumeAllowed(adminTokenCred mcclient.TokenCredential, targetUserId string, scopedProject *models.SProject) error {
|
||||
if policy.PolicyManager.Allow(rbacscope.ScopeSystem, adminTokenCred, api.SERVICE_TYPE, "tokens", "perform", "assume").Result.IsDeny() {
|
||||
return httperrors.NewForbiddenError("%s not allow to assume user in project %s", adminTokenCred.GetUserName(), scopedProject.Name)
|
||||
}
|
||||
roles, err := models.AssignmentManager.FetchUserProjectRoles(targetUserId, scopedProject.Id)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "fetch target user roles")
|
||||
}
|
||||
roleIds := make([]string, len(roles))
|
||||
for i := range roles {
|
||||
roleIds[i] = roles[i].Id
|
||||
}
|
||||
if err := models.ValidateJoinProjectRoles(adminTokenCred, scopedProject.Id, roleIds); err != nil {
|
||||
return errors.Wrap(err, "validate assume target roles")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
29
pkg/keystone/tokens/assume_test.go
Normal file
29
pkg/keystone/tokens/assume_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package tokens
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
)
|
||||
|
||||
func TestAuthUserByAssumeRequiresAdminToken(t *testing.T) {
|
||||
_, err := authUserByAssume(context.Background(), mcclient.SAuthenticationInputV3{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when admin token is missing")
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,20 @@ func newOpenAI() models.ILLMClient {
|
||||
return new(openai)
|
||||
}
|
||||
|
||||
func setAiproxyRequestHeaders(ctx context.Context, httpReq *http.Request, mcpAgent *models.SMCPAgent) error {
|
||||
apiKey, err := mcpAgent.GetAiproxyVirtualKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
if rid := strings.TrimSpace(mcpAgent.AiproxyRoutingId); rid != "" {
|
||||
httpReq.Header.Set("X-Ai-Routing-Id", rid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *openai) GetType() api.LLMClientType {
|
||||
return api.LLM_CLIENT_OPENAI
|
||||
}
|
||||
@@ -283,13 +297,9 @@ func (o *openai) doChatStreamRequest(ctx context.Context, mcpAgent *models.SMCPA
|
||||
return errors.Wrap(err, "create request")
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
apiKey, err := mcpAgent.GetApiKey()
|
||||
if err != nil {
|
||||
if err := setAiproxyRequestHeaders(ctx, httpReq, mcpAgent); err != nil {
|
||||
return err
|
||||
}
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
// Stream request no timeout
|
||||
@@ -372,12 +382,8 @@ func (o *openai) doChatRequest(ctx context.Context, mcpAgent *models.SMCPAgent,
|
||||
return nil, errors.Wrap(err, "create request")
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
apiKey, err := mcpAgent.GetApiKey()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "get apiKey")
|
||||
}
|
||||
if apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
if err := setAiproxyRequestHeaders(ctx, httpReq, mcpAgent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
|
||||
@@ -131,11 +131,10 @@ func resolveHermesAgentSpec(ctx context.Context, userCred mcclient.TokenCredenti
|
||||
return out, nil
|
||||
}
|
||||
|
||||
llmObj, err := models.GetLLMManager().FetchByIdOrName(ctx, userCred, out.LLMId)
|
||||
targetLLM, err := models.FetchAccessibleLLM(ctx, userCred, out.LLMId)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "fetch target LLM %s", out.LLMId)
|
||||
}
|
||||
targetLLM := llmObj.(*models.SLLM)
|
||||
targetSku, err := targetLLM.GetLLMSku(targetLLM.LLMSkuId)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "fetch target LLM SKU")
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/notifyclient"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/policy"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/llm/options"
|
||||
llmutils "yunion.io/x/onecloud/pkg/llm/utils"
|
||||
@@ -53,6 +54,23 @@ func GetLLMManager() *SLLMManager {
|
||||
return llmManager
|
||||
}
|
||||
|
||||
func requireLLMGetAllowed(ctx context.Context, userCred mcclient.TokenCredential, llm *SLLM) error {
|
||||
return db.IsObjectRbacAllowed(ctx, llm, userCred, policy.PolicyActionGet)
|
||||
}
|
||||
|
||||
// FetchAccessibleLLM fetches an LLM by id or name and requires get permission.
|
||||
func FetchAccessibleLLM(ctx context.Context, userCred mcclient.TokenCredential, idStr string) (*SLLM, error) {
|
||||
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, strings.TrimSpace(idStr))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
llm := llmObj.(*SLLM)
|
||||
if err := requireLLMGetAllowed(ctx, userCred, llm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return llm, nil
|
||||
}
|
||||
|
||||
type SLLMManager struct {
|
||||
SLLMBaseManager
|
||||
}
|
||||
@@ -551,15 +569,12 @@ func (llm *SLLM) SetStatus(ctx context.Context, userCred mcclient.TokenCredentia
|
||||
if err := dep.SyncReadyReplicas(ctx, userCred); err != nil {
|
||||
log.Warningf("SLLM.SetStatus: SyncReadyReplicas for deployment %s: %s", llm.LLMDeploymentId, err)
|
||||
}
|
||||
if dep.AutoRegisterAiproxy {
|
||||
if status == api.LLM_STATUS_RUNNING {
|
||||
if err := dep.StartAiproxySyncTask(ctx, userCred, llm.Id, ""); err != nil {
|
||||
log.Warningf("SLLM.SetStatus: start aiproxy sync for llm %s: %v", llm.Id, err)
|
||||
}
|
||||
} else if oldStatus == api.LLM_STATUS_RUNNING && status != api.LLM_STATUS_RUNNING {
|
||||
if err := UnsyncLlmInstance(ctx, userCred, dep, llm.Id); err != nil {
|
||||
log.Warningf("SLLM.SetStatus: unsync aiproxy for llm %s: %v", llm.Id, err)
|
||||
}
|
||||
// Restart/stop must not delete aiproxy routing or providers; catalog is
|
||||
// removed only on deployment delete or manual unregister. Reconcile on
|
||||
// running upserts the same routing/provider IDs (e.g. new pod URL).
|
||||
if dep.AutoRegisterAiproxy && status == api.LLM_STATUS_RUNNING {
|
||||
if err := dep.StartAiproxySyncTask(ctx, userCred, llm.Id, ""); err != nil {
|
||||
log.Warningf("SLLM.SetStatus: start aiproxy sync for llm %s: %v", llm.Id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -935,7 +935,21 @@ func DeleteDeploymentAiproxyResources(ctx context.Context, deploymentId string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsyncLlmInstance removes aiproxy resources for one llm replica.
|
||||
// shouldUnsyncAiproxyOnLeaveRunning reports whether leaving running should
|
||||
// unbind/delete aiproxy catalog. Restart and stop keep routing IDs stable;
|
||||
// catalog is only removed on deployment delete or manual unregister.
|
||||
func shouldUnsyncAiproxyOnLeaveRunning() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// unsyncKeepsDeploymentRouting returns the routing id to persist after
|
||||
// UnsyncLlmInstance. The deployment-level ai_routing row is never deleted here.
|
||||
func unsyncKeepsDeploymentRouting(existingRoutingId string) (keepRoutingId string, deleteRouting bool) {
|
||||
return strings.TrimSpace(existingRoutingId), false
|
||||
}
|
||||
|
||||
// UnsyncLlmInstance unbinds one llm replica from the deployment routing.
|
||||
// It does not delete the deployment's ai_routing or clear AiproxyRoutingId.
|
||||
func UnsyncLlmInstance(ctx context.Context, userCred mcclient.TokenCredential, dep *SLLMDeployment, llmId string) error {
|
||||
if dep == nil || strings.TrimSpace(llmId) == "" {
|
||||
return nil
|
||||
@@ -953,25 +967,23 @@ func UnsyncLlmInstance(ctx context.Context, userCred mcclient.TokenCredential, d
|
||||
}
|
||||
}
|
||||
|
||||
routingId := strings.TrimSpace(dep.AiproxyRoutingId)
|
||||
if routingId == "" {
|
||||
if err := deleteAiProviderByLlmId(session, llmId); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := persistDeploymentAiproxyBindings(dep, "", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return dep.SetAiproxySyncStatus(ctx, userCred, api.AIPROXY_SYNC_STATUS_PENDING, "waiting for running replicas")
|
||||
}
|
||||
|
||||
if len(remaining) == 0 {
|
||||
routingId, deleteRouting := unsyncKeepsDeploymentRouting(dep.AiproxyRoutingId)
|
||||
if deleteRouting {
|
||||
if err := deleteAiRoutingById(session, routingId); err != nil {
|
||||
log.Warningf("delete ai_routing %s: %v", routingId, err)
|
||||
}
|
||||
routingId = ""
|
||||
}
|
||||
if routingId == "" || len(remaining) == 0 {
|
||||
if routingId != "" {
|
||||
if err := applyRoutingModels(session, routingId, []apapi.AiRoutingModelItem{}); err != nil {
|
||||
log.Warningf("clear ai_routing models %s: %v", routingId, err)
|
||||
}
|
||||
}
|
||||
if err := deleteAiProviderByLlmId(session, llmId); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := persistDeploymentAiproxyBindings(dep, "", nil); err != nil {
|
||||
if err := persistDeploymentAiproxyBindings(dep, routingId, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return dep.SetAiproxySyncStatus(ctx, userCred, api.AIPROXY_SYNC_STATUS_PENDING, "waiting for running replicas")
|
||||
|
||||
49
pkg/llm/models/llm_aiproxy_sync_test.go
Normal file
49
pkg/llm/models/llm_aiproxy_sync_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/llm"
|
||||
)
|
||||
|
||||
func TestShouldUnsyncAiproxyOnLeaveRunning(t *testing.T) {
|
||||
if shouldUnsyncAiproxyOnLeaveRunning() {
|
||||
t.Fatal("leaving running must not unsync aiproxy; catalog is removed only on deployment delete or unregister")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsyncKeepsDeploymentRouting(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "keeps existing id on last replica", in: "routing-abc", want: "routing-abc"},
|
||||
{name: "trims space", in: " routing-abc ", want: "routing-abc"},
|
||||
{name: "empty stays empty", in: "", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, deleteRouting := unsyncKeepsDeploymentRouting(tc.in)
|
||||
if deleteRouting {
|
||||
t.Fatalf("deleteRouting = true, want false (routing must survive unsync)")
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("keepRoutingId = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAiproxyCatalogDeleteEntrypoints(t *testing.T) {
|
||||
if api.AIPROXY_SYNC_STATUS_DISABLED == "" {
|
||||
t.Fatal("disabled status must remain the unregister/delete end state")
|
||||
}
|
||||
if shouldUnsyncAiproxyOnLeaveRunning() {
|
||||
t.Fatal("status leave-running is not a catalog delete entrypoint")
|
||||
}
|
||||
_, deleteRouting := unsyncKeepsDeploymentRouting("keep-me")
|
||||
if deleteRouting {
|
||||
t.Fatal("UnsyncLlmInstance must not delete deployment routing")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db/lockman"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/db/taskman"
|
||||
"yunion.io/x/onecloud/pkg/cloudcommon/policy"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
bench "yunion.io/x/onecloud/pkg/llm/benchmark"
|
||||
"yunion.io/x/onecloud/pkg/llm/options"
|
||||
@@ -344,6 +345,9 @@ func resolveBenchmarkTarget(ctx context.Context, userCred mcclient.TokenCredenti
|
||||
return nil, nil, errors.Wrap(err, "fetch LLMDeployment")
|
||||
}
|
||||
dep := depObj.(*SLLMDeployment)
|
||||
if err := db.IsObjectRbacAllowed(ctx, dep, userCred, policy.PolicyActionGet); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
llm := &SLLM{}
|
||||
err = GetLLMManager().Query().
|
||||
Equals("llm_deployment_id", dep.Id).
|
||||
@@ -362,11 +366,10 @@ func resolveBenchmarkTarget(ctx context.Context, userCred mcclient.TokenCredenti
|
||||
if input.LLMId == "" {
|
||||
return nil, nil, errors.Wrap(httperrors.ErrMissingParameter, "llm_id or llm_deployment_id")
|
||||
}
|
||||
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, input.LLMId)
|
||||
llm, err := FetchAccessibleLLM(ctx, userCred, input.LLMId)
|
||||
if err != nil {
|
||||
return nil, nil, errors.Wrap(err, "fetch LLM")
|
||||
}
|
||||
llm := llmObj.(*SLLM)
|
||||
if llm.Status != api.LLM_STATUS_RUNNING {
|
||||
return nil, nil, errors.Wrapf(httperrors.ErrInvalidStatus, "llm %s status is %s", llm.Name, llm.Status)
|
||||
}
|
||||
|
||||
@@ -217,11 +217,10 @@ func resolveRouterAgentLLM(ctx context.Context, userCred mcclient.TokenCredentia
|
||||
if llmId == nil || strings.TrimSpace(*llmId) == "" {
|
||||
return nil
|
||||
}
|
||||
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, strings.TrimSpace(*llmId))
|
||||
llm, err := FetchAccessibleLLM(ctx, userCred, *llmId)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "fetch LLM by id %s", *llmId)
|
||||
}
|
||||
llm := llmObj.(*SLLM)
|
||||
*llmId = llm.Id
|
||||
info, err := llm.GetLLMAccessUrlInfo(ctx, userCred, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/log"
|
||||
"yunion.io/x/pkg/errors"
|
||||
seclib "yunion.io/x/pkg/utils"
|
||||
"yunion.io/x/sqlchemy"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/llm"
|
||||
@@ -23,6 +22,7 @@ import (
|
||||
"yunion.io/x/onecloud/pkg/llm/options"
|
||||
"yunion.io/x/onecloud/pkg/llm/utils"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
apmodules "yunion.io/x/onecloud/pkg/mcclient/modules/aiproxy"
|
||||
"yunion.io/x/onecloud/pkg/util/stringutils2"
|
||||
)
|
||||
|
||||
@@ -119,17 +119,21 @@ func (man *SMCPAgentManager) GetDefaultMcpServerTools(ctx context.Context, userC
|
||||
type SMCPAgent struct {
|
||||
db.SSharableVirtualResourceBase
|
||||
|
||||
// LLMId 关联的 LLM 实例 ID
|
||||
LLMId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"optional" update:"user"`
|
||||
// LLMId 旧字段,新建不再写入
|
||||
LLMId string `width:"128" charset:"ascii" nullable:"true" list:"user"`
|
||||
// AiproxyRoutingId 关联的 AI 网关路由规则 ID
|
||||
AiproxyRoutingId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"required" update:"user" json:"aiproxy_routing_id"`
|
||||
// AiproxyVirtualKeyId 关联的 AI 网关 API Key ID(密钥只存在 aiproxy,聊天时按 ID 读取)
|
||||
AiproxyVirtualKeyId string `width:"128" charset:"ascii" nullable:"true" list:"user" create:"required" update:"user" json:"aiproxy_virtual_key_id"`
|
||||
|
||||
// LLMUrl 对应后端大模型的 base 请求地址
|
||||
// LLMUrl 对应 aiproxy OpenAI 兼容 base 请求地址
|
||||
LLMUrl string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"required" update:"user"`
|
||||
// LLMDriver 对应使用的大模型驱动(llm_client),现在可以被设置为 ollama 或 openai
|
||||
// LLMDriver 固定为 openai(走 AI 网关)
|
||||
LLMDriver string `width:"64" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
|
||||
// Model 使用的模型名称
|
||||
Model string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
|
||||
// ApiKey 即在 llm_driver 中需要用到的认证
|
||||
ApiKey string `width:"512" charset:"utf8" nullable:"true" list:"user" create:"optional" update:"user"`
|
||||
// Model 使用的模型名称(可为 aiproxy 扁平或层级 client model id)
|
||||
Model string `width:"256" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"`
|
||||
// ApiKey 旧字段,新建不再写入
|
||||
ApiKey string `width:"512" charset:"utf8" nullable:"true"`
|
||||
// McpServer 即 mcp 服务器的后端地址
|
||||
McpServer string `width:"512" charset:"utf8" nullable:"false" list:"user" create:"optional" update:"user"`
|
||||
// DefaultAgent 是否为默认 Agent,全局仅允许一条为 true
|
||||
@@ -140,32 +144,11 @@ func (mcp *SMCPAgent) BeforeInsert() {
|
||||
if len(mcp.Id) == 0 {
|
||||
mcp.Id = db.DefaultUUIDGenerator()
|
||||
}
|
||||
if len(mcp.ApiKey) > 0 {
|
||||
sec, err := seclib.EncryptAESBase64(mcp.Id, mcp.ApiKey)
|
||||
if err != nil {
|
||||
log.Errorf("EncryptAESBase64 fail %s", err)
|
||||
} else {
|
||||
mcp.ApiKey = sec
|
||||
}
|
||||
}
|
||||
mcp.ApiKey = ""
|
||||
mcp.LLMId = ""
|
||||
mcp.SSharableVirtualResourceBase.BeforeInsert()
|
||||
}
|
||||
|
||||
func (mcp *SMCPAgent) BeforeUpdate() {
|
||||
if len(mcp.ApiKey) > 0 {
|
||||
// heuristic to check if it is plaintext
|
||||
_, err := seclib.DescryptAESBase64(mcp.Id, mcp.ApiKey)
|
||||
if err != nil {
|
||||
sec, err := seclib.EncryptAESBase64(mcp.Id, mcp.ApiKey)
|
||||
if err != nil {
|
||||
log.Errorf("EncryptAESBase64 fail %s", err)
|
||||
} else {
|
||||
mcp.ApiKey = sec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mcp *SMCPAgent) PostCreate(ctx context.Context, userCred mcclient.TokenCredential, ownerId mcclient.IIdentityProvider, query jsonutils.JSONObject, data jsonutils.JSONObject) {
|
||||
mcp.SSharableVirtualResourceBase.PostCreate(ctx, userCred, ownerId, query, data)
|
||||
if mcp.DefaultAgent {
|
||||
@@ -182,18 +165,36 @@ func (mcp *SMCPAgent) PostUpdate(ctx context.Context, userCred mcclient.TokenCre
|
||||
log.Errorf("unsetOtherDefaultAgents after update: %v", err)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(mcp.ApiKey) != "" || strings.TrimSpace(mcp.LLMId) != "" {
|
||||
if _, err := db.Update(mcp, func() error {
|
||||
mcp.ApiKey = ""
|
||||
mcp.LLMId = ""
|
||||
return nil
|
||||
}); err != nil {
|
||||
log.Errorf("clear mcp agent llm_id/api_key: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mcp *SMCPAgent) GetApiKey() (string, error) {
|
||||
if len(mcp.ApiKey) == 0 {
|
||||
return "", nil
|
||||
func (mcp *SMCPAgent) GetAiproxyVirtualKey(ctx context.Context) (string, error) {
|
||||
id := strings.TrimSpace(mcp.AiproxyVirtualKeyId)
|
||||
if id == "" {
|
||||
return "", errors.Wrap(httperrors.ErrInvalidStatus, "mcp agent has no aiproxy_virtual_key_id; update the agent to bind an AI gateway API key")
|
||||
}
|
||||
// try decrypt
|
||||
key, err := seclib.DescryptAESBase64(mcp.Id, mcp.ApiKey)
|
||||
if err == nil {
|
||||
return key, nil
|
||||
session := aiproxyAdminSession(ctx)
|
||||
if session == nil {
|
||||
return "", errors.Wrap(httperrors.ErrInvalidStatus, "aiproxy admin session is nil")
|
||||
}
|
||||
return mcp.ApiKey, nil
|
||||
resp, err := apmodules.AiVirtualKeys.Get(session, id, nil)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "get ai_virtual_key %s", id)
|
||||
}
|
||||
key, _ := resp.GetString("virtual_key")
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", errors.Wrapf(httperrors.ErrInvalidStatus, "ai_virtual_key %s has empty virtual_key", id)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (man *SMCPAgentManager) CustomizeHandlerInfo(info *appsrv.SHandlerInfo) {
|
||||
@@ -228,61 +229,38 @@ func (man *SMCPAgentManager) ValidateCreateData(ctx context.Context, userCred mc
|
||||
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
|
||||
}
|
||||
|
||||
// 如果提供了 llm_id,则通过 LLM 获取 llm_url 和 model
|
||||
if len(input.LLMId) > 0 {
|
||||
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, input.LLMId)
|
||||
if err != nil {
|
||||
return input, errors.Wrapf(err, "fetch LLM by id %s", input.LLMId)
|
||||
}
|
||||
llm := llmObj.(*SLLM)
|
||||
input.LLMId = llm.Id
|
||||
llmUrl, err := llm.GetLLMAccessUrlInfo(ctx, userCred, query)
|
||||
if err != nil {
|
||||
return input, errors.Wrapf(err, "get LLM URL from LLM %s", input.LLMId)
|
||||
}
|
||||
input.LLMUrl = llmUrl.LoginUrl
|
||||
|
||||
if len(input.Model) == 0 {
|
||||
mdlInfos, err := llm.getProbedInstantModelsExt(ctx, userCred)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "get probed models from LLM instance")
|
||||
}
|
||||
if len(mdlInfos) == 0 {
|
||||
return input, httperrors.NewBadRequestError("no available models found in LLM instance %s", input.LLMId)
|
||||
}
|
||||
var firstModel api.LLMInternalInstantMdlInfo
|
||||
for _, mdlInfo := range mdlInfos {
|
||||
firstModel = mdlInfo
|
||||
break
|
||||
}
|
||||
input.Model = fmt.Sprintf("%s:%s", firstModel.Name, firstModel.Tag)
|
||||
}
|
||||
input.LLMId = ""
|
||||
input.ApiKey = ""
|
||||
input.AiProxyRoutingId = strings.TrimSpace(input.AiProxyRoutingId)
|
||||
input.AiproxyVirtualKeyId = strings.TrimSpace(input.AiproxyVirtualKeyId)
|
||||
if input.AiProxyRoutingId == "" {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_routing_id is required")
|
||||
}
|
||||
if input.AiproxyVirtualKeyId == "" {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_virtual_key_id is required")
|
||||
}
|
||||
|
||||
// 验证 llm_url 不为空
|
||||
if len(input.LLMUrl) == 0 {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url is required (or provide llm_id to auto-fetch)")
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "llm_url is required")
|
||||
}
|
||||
|
||||
// 验证 llm_driver 必须是 ollama 或 openai
|
||||
input.LLMDriver = strings.ToLower(strings.TrimSpace(input.LLMDriver))
|
||||
if !api.IsLLMClientType(input.LLMDriver) {
|
||||
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be one of: %s, got: %s", api.LLM_CLIENT_TYPES.List(), input.LLMDriver)
|
||||
if input.LLMDriver == "" {
|
||||
input.LLMDriver = string(api.LLM_CLIENT_OPENAI)
|
||||
}
|
||||
if input.LLMDriver != string(api.LLM_CLIENT_OPENAI) {
|
||||
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be %s", api.LLM_CLIENT_OPENAI)
|
||||
}
|
||||
|
||||
// 验证 model 不为空
|
||||
if len(input.Model) == 0 {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "model is required")
|
||||
}
|
||||
|
||||
// 验证 mcp_server 不为空
|
||||
if len(input.McpServer) == 0 {
|
||||
input.McpServer = options.Options.MCPServerURL
|
||||
}
|
||||
|
||||
// 对于 openai 驱动,api_key 是必需的
|
||||
if input.LLMDriver == string(api.LLM_CLIENT_OPENAI) && len(input.ApiKey) == 0 {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "api_key is required when llm_driver is openai")
|
||||
if err := utils.ValidateMCPServerURL(input.McpServer); err != nil {
|
||||
return input, httperrors.NewInputParameterError("%s", err.Error())
|
||||
}
|
||||
|
||||
input.Status = api.STATUS_READY
|
||||
@@ -296,42 +274,42 @@ func (man *SMCPAgentManager) ValidateUpdateData(ctx context.Context, userCred mc
|
||||
return input, errors.Wrap(err, "validate SharableVirtualResourceCreateInput")
|
||||
}
|
||||
|
||||
// 如果提供了 llm_id,则通过 LLM 获取 llm_url 和 model
|
||||
if input.LLMId != nil && len(*input.LLMId) > 0 {
|
||||
llmObj, err := GetLLMManager().FetchByIdOrName(ctx, userCred, *input.LLMId)
|
||||
if err != nil {
|
||||
return input, errors.Wrapf(err, "fetch LLM by id %s", *input.LLMId)
|
||||
}
|
||||
llm := llmObj.(*SLLM)
|
||||
llmUrl, err := llm.GetLLMAccessUrlInfo(ctx, userCred, query)
|
||||
if err != nil {
|
||||
return input, errors.Wrapf(err, "get LLM URL from LLM %s", *input.LLMId)
|
||||
}
|
||||
input.LLMUrl = &llmUrl.LoginUrl
|
||||
input.LLMId = nil
|
||||
input.ApiKey = nil
|
||||
|
||||
if input.Model == nil || len(*input.Model) == 0 {
|
||||
mdlInfos, err := llm.getProbedInstantModelsExt(ctx, userCred)
|
||||
if err != nil {
|
||||
return input, errors.Wrap(err, "get probed models from LLM instance")
|
||||
}
|
||||
if len(mdlInfos) == 0 {
|
||||
return input, httperrors.NewBadRequestError("no available models found in LLM instance %s", *input.LLMId)
|
||||
}
|
||||
var firstModel api.LLMInternalInstantMdlInfo
|
||||
for _, mdlInfo := range mdlInfos {
|
||||
firstModel = mdlInfo
|
||||
break
|
||||
}
|
||||
modelStr := fmt.Sprintf("%s:%s", firstModel.Name, firstModel.Tag)
|
||||
input.Model = &modelStr
|
||||
if input.AiProxyRoutingId != nil {
|
||||
trimmed := strings.TrimSpace(*input.AiProxyRoutingId)
|
||||
if trimmed == "" {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_routing_id is required")
|
||||
}
|
||||
input.AiProxyRoutingId = &trimmed
|
||||
}
|
||||
if input.AiproxyVirtualKeyId != nil {
|
||||
trimmed := strings.TrimSpace(*input.AiproxyVirtualKeyId)
|
||||
if trimmed == "" {
|
||||
return input, errors.Wrap(httperrors.ErrInputParameter, "aiproxy_virtual_key_id is required")
|
||||
}
|
||||
input.AiproxyVirtualKeyId = &trimmed
|
||||
}
|
||||
|
||||
if input.LLMDriver != nil {
|
||||
*input.LLMDriver = strings.ToLower(strings.TrimSpace(*input.LLMDriver))
|
||||
if *input.LLMDriver == "" {
|
||||
openai := string(api.LLM_CLIENT_OPENAI)
|
||||
input.LLMDriver = &openai
|
||||
}
|
||||
if *input.LLMDriver != string(api.LLM_CLIENT_OPENAI) {
|
||||
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be %s", api.LLM_CLIENT_OPENAI)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果更新 llm_driver,验证其值
|
||||
if input.LLMDriver != nil {
|
||||
*input.LLMDriver = strings.ToLower(strings.TrimSpace(*input.LLMDriver))
|
||||
if !api.IsLLMClientType(*input.LLMDriver) {
|
||||
return input, errors.Wrapf(httperrors.ErrInputParameter, "llm_driver must be one of: %s, got: %s", api.LLM_CLIENT_TYPES.List(), *input.LLMDriver)
|
||||
if input.McpServer != nil {
|
||||
if len(*input.McpServer) == 0 {
|
||||
def := options.Options.MCPServerURL
|
||||
input.McpServer = &def
|
||||
}
|
||||
if err := utils.ValidateMCPServerURL(*input.McpServer); err != nil {
|
||||
return input, httperrors.NewInputParameterError("%s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,29 +350,11 @@ func (manager *SMCPAgentManager) FetchCustomizeColumns(
|
||||
agents := []SMCPAgent{}
|
||||
jsonutils.Update(&agents, objs)
|
||||
|
||||
llmIds := make([]string, 0)
|
||||
for i := range agents {
|
||||
if len(agents[i].LLMId) > 0 {
|
||||
llmIds = append(llmIds, agents[i].LLMId)
|
||||
}
|
||||
}
|
||||
|
||||
var llmIdNameMap map[string]string
|
||||
if len(llmIds) > 0 {
|
||||
var err error
|
||||
llmIdNameMap, err = db.FetchIdNameMap2(GetLLMManager(), llmIds)
|
||||
if err != nil {
|
||||
log.Errorf("FetchIdNameMap2 for LLMs failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range rows {
|
||||
rows[i].SharableVirtualResourceDetails = vrows[i]
|
||||
if i < len(agents) {
|
||||
rows[i].LLMId = agents[i].LLMId
|
||||
if name, ok := llmIdNameMap[agents[i].LLMId]; ok {
|
||||
rows[i].LLMName = name
|
||||
}
|
||||
rows[i].AiProxyRoutingId = agents[i].AiproxyRoutingId
|
||||
rows[i].AiproxyVirtualKeyId = agents[i].AiproxyVirtualKeyId
|
||||
rows[i].DefaultAgent = agents[i].DefaultAgent
|
||||
}
|
||||
}
|
||||
@@ -407,10 +367,14 @@ func (mcp *SMCPAgent) GetLLMClientDriver() ILLMClient {
|
||||
}
|
||||
|
||||
func (mcp *SMCPAgent) GetMcpServerUrl(ctx context.Context, userCred mcclient.TokenCredential) (string, error) {
|
||||
if len(mcp.McpServer) > 0 {
|
||||
return mcp.McpServer, nil
|
||||
serverURL := mcp.McpServer
|
||||
if len(serverURL) == 0 {
|
||||
serverURL = options.Options.MCPServerURL
|
||||
}
|
||||
return options.Options.MCPServerURL, nil
|
||||
if err := utils.ValidateMCPServerURL(serverURL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return serverURL, nil
|
||||
}
|
||||
|
||||
func (mcp *SMCPAgent) GetDetailsMcpTools(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
||||
@@ -560,6 +524,12 @@ func friendlyChatStreamError(err error) string {
|
||||
|
||||
// process 处理用户请求(多轮工具调用,直到模型不再发 tool_calls 或达到上限)
|
||||
func (mcp *SMCPAgent) process(ctx context.Context, userCred mcclient.TokenCredential, req *api.LLMMCPAgentRequestInput, onStream func(string) error) (*api.MCPAgentResponse, error) {
|
||||
if strings.TrimSpace(mcp.AiproxyRoutingId) == "" {
|
||||
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "mcp agent has no aiproxy_routing_id; update the agent to bind an AI gateway routing rule")
|
||||
}
|
||||
if strings.TrimSpace(mcp.AiproxyVirtualKeyId) == "" {
|
||||
return nil, errors.Wrap(httperrors.ErrInvalidStatus, "mcp agent has no aiproxy_virtual_key_id; update the agent to bind an AI gateway API key")
|
||||
}
|
||||
mcpServerUrl, err := mcp.GetMcpServerUrl(ctx, userCred)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetMcpServerUrl")
|
||||
|
||||
@@ -304,9 +304,21 @@ func handleDefaultMcpTools(ctx context.Context, w http.ResponseWriter, r *http.R
|
||||
appsrv.SendJSON(w, result)
|
||||
}
|
||||
|
||||
func registerLLMRouterAgentRoute(app *appsrv.Application) {
|
||||
app.AddHandler2("POST", "/llm_router_agents/<id>/route", auth.Authenticate(handleLLMRouterAgentRoute), nil, "llm_router_agent_route", nil)
|
||||
}
|
||||
|
||||
func handleLLMRouterAgentRoute(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
userCred := auth.FetchUserCredential(ctx, policy.FilterPolicyCredential)
|
||||
if userCred == nil {
|
||||
httperrors.UnauthorizedError(ctx, w, "Unauthorized")
|
||||
return
|
||||
}
|
||||
params, _, body := appsrv.FetchEnv(ctx, w, r)
|
||||
id := params["<id>"]
|
||||
id := ""
|
||||
if params != nil {
|
||||
id = params["<id>"]
|
||||
}
|
||||
if id == "" {
|
||||
httperrors.MissingParameterError(ctx, w, "id")
|
||||
return
|
||||
@@ -325,12 +337,16 @@ func handleLLMRouterAgentRoute(ctx context.Context, w http.ResponseWriter, r *ht
|
||||
httperrors.InvalidInputError(ctx, w, "invalid input: %v", err)
|
||||
return
|
||||
}
|
||||
obj, err := models.GetLLMRouterAgentManager().FetchByIdOrName(ctx, nil, id)
|
||||
obj, err := models.GetLLMRouterAgentManager().FetchByIdOrName(ctx, userCred, id)
|
||||
if err != nil {
|
||||
httperrors.GeneralServerError(ctx, w, err)
|
||||
return
|
||||
}
|
||||
agent := obj.(*models.SLLMRouterAgent)
|
||||
if err := db.IsObjectRbacAllowed(ctx, agent, userCred, policy.PolicyActionPerform, "route"); err != nil {
|
||||
httperrors.GeneralServerError(ctx, w, err)
|
||||
return
|
||||
}
|
||||
out, err := agent.Route(ctx, input)
|
||||
if err != nil {
|
||||
httperrors.GeneralServerError(ctx, w, err)
|
||||
@@ -378,7 +394,7 @@ func InitHandlers(app *appsrv.Application, isSlave bool) {
|
||||
// 默认 MCP 服务器 tools:仅使用 options.MCPServerURL,不依赖 mcp_agent 条目
|
||||
app.AddHandler2("GET", "/mcp_agents/default-mcp-tools", auth.Authenticate(handleDefaultMcpTools), nil, "default_mcp_tools", nil)
|
||||
|
||||
app.AddHandler2("POST", "/llm_router_agents/<id>/route", handleLLMRouterAgentRoute, nil, "llm_router_agent_route", nil)
|
||||
registerLLMRouterAgentRoute(app)
|
||||
|
||||
for _, manager := range []db.IModelManager{
|
||||
taskman.TaskManager,
|
||||
|
||||
47
pkg/llm/service/handler_route_test.go
Normal file
47
pkg/llm/service/handler_route_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/appsrv"
|
||||
)
|
||||
|
||||
func TestLLMRouterAgentRouteRequiresAuth(t *testing.T) {
|
||||
app := appsrv.NewApplication("test-llm-router-route", 1, 1, false)
|
||||
registerLLMRouterAgentRoute(app)
|
||||
|
||||
req := httptest.NewRequest("POST", "/llm_router_agents/some-id/route", strings.NewReader(`{"prompt":"hi"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("POST /llm_router_agents/<id>/route: status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleLLMRouterAgentRouteUnauthorized(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/llm_router_agents/some-id/route", strings.NewReader(`{"prompt":"hi"}`))
|
||||
w := httptest.NewRecorder()
|
||||
handleLLMRouterAgentRoute(context.Background(), w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no session: status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
@@ -83,17 +83,24 @@ func NewMCPClient(serverURL string, timeout time.Duration, userCred mcclient.Tok
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Minute
|
||||
}
|
||||
var cred mcclient.TokenCredential
|
||||
if IsTrustedMCPServerURL(serverURL) {
|
||||
cred = userCred
|
||||
}
|
||||
return &MCPClient{
|
||||
serverURL: strings.TrimSuffix(serverURL, "/"),
|
||||
serverURL: strings.TrimSuffix(strings.TrimSpace(serverURL), "/"),
|
||||
client: &http.Client{
|
||||
Timeout: 0,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
},
|
||||
},
|
||||
requestTimeout: timeout,
|
||||
userCred: userCred,
|
||||
userCred: cred,
|
||||
pendingReqs: make(map[int64]chan *rawMCPResponse),
|
||||
}
|
||||
}
|
||||
@@ -110,8 +117,13 @@ func (c *MCPClient) setAuthHeaders(req *http.Request) {
|
||||
|
||||
// connectSSE 连接 SSE 端点并开始事件循环
|
||||
func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
// 连接 SSE 端点获取 session URL
|
||||
sseURL := c.serverURL + "/sse"
|
||||
if err := ValidateMCPServerURL(c.serverURL); err != nil {
|
||||
return err
|
||||
}
|
||||
sseURL, err := joinMCPEndpoint(c.serverURL, "/sse")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", sseURL, nil)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create SSE request")
|
||||
@@ -126,9 +138,9 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
return errors.Errorf("SSE connection failed with status %d: %s", resp.StatusCode, string(body))
|
||||
return errors.Errorf("SSE connection failed")
|
||||
}
|
||||
|
||||
c.sseBody = resp.Body
|
||||
@@ -160,7 +172,14 @@ func (c *MCPClient) connectSSE(ctx context.Context) error {
|
||||
dataLines = dataLines[:0]
|
||||
if !foundSession {
|
||||
if strings.Contains(data, "/message") {
|
||||
c.sessionURL = c.serverURL + data
|
||||
sessionURL, err := joinMCPEndpoint(c.serverURL, data)
|
||||
if err != nil {
|
||||
initErr = err
|
||||
foundSession = true
|
||||
close(done)
|
||||
return
|
||||
}
|
||||
c.sessionURL = sessionURL
|
||||
log.Infof("MCP Client initialized with session URL: %s", c.sessionURL)
|
||||
foundSession = true
|
||||
close(done)
|
||||
|
||||
147
pkg/llm/utils/mcp_url.go
Normal file
147
pkg/llm/utils/mcp_url.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"yunion.io/x/pkg/errors"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/llm/options"
|
||||
)
|
||||
|
||||
func ValidateMCPServerURL(raw string) error {
|
||||
u, err := parseMCPServerURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if IsTrustedMCPServerURL(raw) {
|
||||
return nil
|
||||
}
|
||||
return rejectRestrictedMCPHost(u.Hostname())
|
||||
}
|
||||
|
||||
func IsTrustedMCPServerURL(raw string) bool {
|
||||
configured := strings.TrimSpace(options.Options.MCPServerURL)
|
||||
if configured == "" {
|
||||
return false
|
||||
}
|
||||
got, err := parseMCPServerURL(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := parseMCPServerURL(configured)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return mcpOrigin(got) == mcpOrigin(want)
|
||||
}
|
||||
|
||||
func parseMCPServerURL(raw string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "invalid mcp server url")
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return nil, errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
if u.Host == "" || u.Opaque != "" || u.User != nil || u.Fragment != "" || strings.Contains(raw, "#") {
|
||||
return nil, errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return nil, errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func rejectRestrictedMCPHost(host string) error {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
ips := []net.IP{}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
} else {
|
||||
resolved, err := net.LookupIP(host)
|
||||
if err != nil || len(resolved) == 0 {
|
||||
return errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
ips = resolved
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isRestrictedMCPIP(ip) {
|
||||
return errors.Errorf("invalid mcp server url")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRestrictedMCPIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
|
||||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
func mcpOrigin(u *url.URL) string {
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
host := strings.ToLower(u.Hostname())
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
if scheme == "https" {
|
||||
port = "443"
|
||||
} else {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
return scheme + "://" + host + ":" + port
|
||||
}
|
||||
|
||||
func joinMCPEndpoint(serverURL, endpoint string) (string, error) {
|
||||
base, err := parseMCPServerURL(serverURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
if endpoint == "" {
|
||||
return "", errors.Errorf("invalid mcp endpoint")
|
||||
}
|
||||
rel, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "invalid mcp endpoint")
|
||||
}
|
||||
if strings.HasPrefix(endpoint, "//") || rel.Opaque != "" || rel.User != nil || rel.Fragment != "" {
|
||||
return "", errors.Errorf("invalid mcp endpoint")
|
||||
}
|
||||
var joined *url.URL
|
||||
if rel.IsAbs() || rel.Host != "" {
|
||||
joined = rel
|
||||
} else {
|
||||
joined = base.ResolveReference(rel)
|
||||
}
|
||||
if mcpOrigin(base) != mcpOrigin(joined) {
|
||||
return "", errors.Errorf("invalid mcp endpoint")
|
||||
}
|
||||
return joined.String(), nil
|
||||
}
|
||||
111
pkg/llm/utils/mcp_url_test.go
Normal file
111
pkg/llm/utils/mcp_url_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Copyright 2019 Yunion
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or authorized to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yunion.io/x/onecloud/pkg/llm/options"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
)
|
||||
|
||||
func TestValidateMCPServerURL(t *testing.T) {
|
||||
prev := options.Options.MCPServerURL
|
||||
options.Options.MCPServerURL = "http://default-mcp-server:30876"
|
||||
defer func() { options.Options.MCPServerURL = prev }()
|
||||
|
||||
if err := ValidateMCPServerURL("http://default-mcp-server:30876"); err != nil {
|
||||
t.Fatalf("configured url: %v", err)
|
||||
}
|
||||
if err := ValidateMCPServerURL("http://default-mcp-server:30876/"); err != nil {
|
||||
t.Fatalf("configured url with slash: %v", err)
|
||||
}
|
||||
if err := ValidateMCPServerURL("https://8.8.8.8"); err != nil {
|
||||
t.Fatalf("public ip: %v", err)
|
||||
}
|
||||
|
||||
for _, raw := range []string{
|
||||
"",
|
||||
"ftp://example.com",
|
||||
"http://127.0.0.1",
|
||||
"http://10.1.2.3",
|
||||
"http://169.254.169.254/",
|
||||
"http://[::1]/",
|
||||
"http://localhost",
|
||||
"http://user:pass@8.8.8.8",
|
||||
"http://8.8.8.8/#/sse",
|
||||
"http://8.8.8.8#",
|
||||
} {
|
||||
if err := ValidateMCPServerURL(raw); err == nil {
|
||||
t.Fatalf("expected error for %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTrustedMCPServerURL(t *testing.T) {
|
||||
prev := options.Options.MCPServerURL
|
||||
options.Options.MCPServerURL = "http://default-mcp-server:30876"
|
||||
defer func() { options.Options.MCPServerURL = prev }()
|
||||
|
||||
if !IsTrustedMCPServerURL("http://default-mcp-server:30876") {
|
||||
t.Fatal("same url should be trusted")
|
||||
}
|
||||
if !IsTrustedMCPServerURL("http://DEFAULT-mcp-server:30876/") {
|
||||
t.Fatal("same origin should be trusted")
|
||||
}
|
||||
if IsTrustedMCPServerURL("https://8.8.8.8") {
|
||||
t.Fatal("other host should not be trusted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMCPClientOmitsCredForUntrusted(t *testing.T) {
|
||||
prev := options.Options.MCPServerURL
|
||||
options.Options.MCPServerURL = "http://default-mcp-server:30876"
|
||||
defer func() { options.Options.MCPServerURL = prev }()
|
||||
|
||||
tok := &mcclient.SSimpleToken{Token: "secret-token"}
|
||||
c := NewMCPClient("https://8.8.8.8", time.Second, tok)
|
||||
if c.userCred != nil {
|
||||
t.Fatal("untrusted url should not keep caller cred")
|
||||
}
|
||||
c = NewMCPClient("http://default-mcp-server:30876", time.Second, tok)
|
||||
if c.userCred == nil {
|
||||
t.Fatal("configured url should keep caller cred")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinMCPEndpoint(t *testing.T) {
|
||||
got, err := joinMCPEndpoint("http://default-mcp-server:30876", "/sse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "http://default-mcp-server:30876/sse" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
got, err = joinMCPEndpoint("http://default-mcp-server:30876", "/message?sessionId=abc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "http://default-mcp-server:30876/message?sessionId=abc" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if _, err := joinMCPEndpoint("http://default-mcp-server:30876", "http://8.8.8.8/message"); err == nil {
|
||||
t.Fatal("absolute other host should fail")
|
||||
}
|
||||
if _, err := joinMCPEndpoint("http://default-mcp-server:30876", "//8.8.8.8/message"); err == nil {
|
||||
t.Fatal("scheme-relative should fail")
|
||||
}
|
||||
}
|
||||
@@ -15,16 +15,8 @@
|
||||
package cloudid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"yunion.io/x/jsonutils"
|
||||
"yunion.io/x/pkg/errors"
|
||||
"yunion.io/x/pkg/utils"
|
||||
|
||||
api "yunion.io/x/onecloud/pkg/apis/compute"
|
||||
"yunion.io/x/onecloud/pkg/httperrors"
|
||||
"yunion.io/x/onecloud/pkg/mcclient"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/modulebase"
|
||||
"yunion.io/x/onecloud/pkg/mcclient/modules"
|
||||
@@ -47,59 +39,5 @@ func init() {
|
||||
}
|
||||
|
||||
func (this *SClouduserManager) GetLoginInfo(s *mcclient.ClientSession, id string, params jsonutils.JSONObject) (jsonutils.JSONObject, error) {
|
||||
data, err := this.Get(s, id, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := struct {
|
||||
Id string
|
||||
Name string
|
||||
Secret string
|
||||
Provider string
|
||||
IamLoginUrl string
|
||||
}{}
|
||||
|
||||
err = data.Unmarshal(&user)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "data.Unmarshal")
|
||||
}
|
||||
|
||||
if len(user.Secret) == 0 {
|
||||
return nil, httperrors.NewNotFoundError("No login secret found")
|
||||
}
|
||||
|
||||
password, err := utils.DescryptAESBase64(user.Id, user.Secret)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "Descrypt")
|
||||
}
|
||||
|
||||
account := ""
|
||||
|
||||
switch user.Provider {
|
||||
case api.CLOUD_PROVIDER_ALIYUN:
|
||||
suffix := strings.TrimPrefix(user.IamLoginUrl, "https://signin.aliyun.com/")
|
||||
suffix = strings.TrimSuffix(suffix, "/login.htm")
|
||||
if len(suffix) > 0 {
|
||||
user.Name = fmt.Sprintf("%s@%s", user.Name, suffix)
|
||||
account = suffix
|
||||
}
|
||||
case api.CLOUD_PROVIDER_QCLOUD, api.CLOUD_PROVIDER_HUAWEI:
|
||||
u, _ := url.Parse(user.IamLoginUrl)
|
||||
if u != nil {
|
||||
account = u.Query().Get("account")
|
||||
}
|
||||
case api.CLOUD_PROVIDER_AWS:
|
||||
account = strings.TrimPrefix(user.IamLoginUrl, "https://")
|
||||
if info := strings.Split(account, "."); len(info) > 0 {
|
||||
account = info[0]
|
||||
}
|
||||
}
|
||||
|
||||
return jsonutils.Marshal(map[string]string{
|
||||
"account": account,
|
||||
"username": user.Name,
|
||||
"password": password,
|
||||
"url": user.IamLoginUrl,
|
||||
}), nil
|
||||
return this.GetSpecific(s, id, "login-info", params)
|
||||
}
|
||||
|
||||
@@ -122,9 +122,13 @@ type ServerSSHLoginOptions struct {
|
||||
type ServerConvertToKvmOptions struct {
|
||||
ServerIdsOptions
|
||||
|
||||
PreferHost string `help:"Prefer host id or name" json:"prefer_host"`
|
||||
DiskBackend string `help:"Prefer disk backend for all disks, e.g. local/lvm/slvm/nfs/rbd" json:"disk_backend"`
|
||||
PreferStorage string `help:"Prefer storage id or name for all disks" json:"prefer_storage"`
|
||||
PreferHost string `help:"Prefer host id or name" json:"prefer_host"`
|
||||
SysDiskBackend string `help:"Prefer disk backend for system disk, e.g. local/lvm/slvm/nfs/rbd" json:"sys_disk_backend"`
|
||||
SysPreferStorage string `help:"Prefer storage id or name for system disk" json:"sys_prefer_storage"`
|
||||
SysDiskMedium string `help:"Prefer medium for system disk, e.g. rotate/ssd/hybrid" json:"sys_disk_medium"`
|
||||
DataDiskBackend string `help:"Prefer disk backend for data disks, e.g. local/lvm/slvm/nfs/rbd" json:"data_disk_backend"`
|
||||
DataPreferStorage string `help:"Prefer storage id or name for data disks" json:"data_prefer_storage"`
|
||||
DataDiskMedium string `help:"Prefer medium for data disks, e.g. rotate/ssd/hybrid" json:"data_disk_medium"`
|
||||
}
|
||||
|
||||
func (o *ServerConvertToKvmOptions) Params() (jsonutils.JSONObject, error) {
|
||||
|
||||
@@ -33,13 +33,13 @@ func (o *MCPAgentShowOptions) Params() (jsonutils.JSONObject, error) {
|
||||
type MCPAgentCreateOptions struct {
|
||||
apis.SharableVirtualResourceCreateInput
|
||||
|
||||
LlmId string `help:"LLM 实例 ID,如果提供则自动获取 llm_url" json:"llm_id"`
|
||||
LLM_URL string `help:"后端大模型的 base 请求地址" json:"llm_url"`
|
||||
LLM_DRIVER string `help:"使用的大模型驱动,可以是 ollama 或 openai" json:"llm_driver" choices:"ollama|openai"`
|
||||
MODEL string `help:"使用的模型名称" json:"model"`
|
||||
API_KEY string `help:"访问大模型的密钥" json:"api_key"`
|
||||
McpServer string `help:"mcp 服务器的后端地址" json:"mcp_server"`
|
||||
DefaultAgent bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent"`
|
||||
LLM_URL string `help:"AI 网关 OpenAI 兼容 base 请求地址" json:"llm_url"`
|
||||
LLM_DRIVER string `help:"使用的大模型驱动,固定为 openai" json:"llm_driver" choices:"openai"`
|
||||
MODEL string `help:"使用的模型名称" json:"model"`
|
||||
McpServer string `help:"mcp 服务器的后端地址" json:"mcp_server"`
|
||||
AiProxyRoutingId string `help:"关联的 AI 网关路由规则 ID" json:"aiproxy_routing_id"`
|
||||
AiproxyVirtualKeyId string `help:"关联的 AI 网关 API Key ID" json:"aiproxy_virtual_key_id"`
|
||||
DefaultAgent bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent"`
|
||||
}
|
||||
|
||||
func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) {
|
||||
@@ -49,14 +49,14 @@ func (o *MCPAgentCreateOptions) Params() (jsonutils.JSONObject, error) {
|
||||
type MCPAgentUpdateOptions struct {
|
||||
apis.SharableVirtualResourceBaseUpdateInput
|
||||
|
||||
ID string
|
||||
LlmId *string `help:"LLM 实例 ID,如果提供则自动获取 llm_url" json:"llm_id,omitempty"`
|
||||
LlmUrl *string `help:"后端大模型的 base 请求地址" json:"llm_url,omitempty"`
|
||||
LlmDriver *string `help:"使用的大模型驱动,可以是 ollama 或 openai" json:"llm_driver,omitempty" choices:"ollama|openai"`
|
||||
Model *string `help:"使用的模型名称" json:"model,omitempty"`
|
||||
ApiKey *string `help:"访问大模型的密钥" json:"api_key,omitempty"`
|
||||
McpServer *string `help:"mcp 服务器的后端地址" json:"mcp_server,omitempty"`
|
||||
DefaultAgent *bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent,omitempty"`
|
||||
ID string
|
||||
LlmUrl *string `help:"AI 网关 OpenAI 兼容 base 请求地址" json:"llm_url,omitempty"`
|
||||
LlmDriver *string `help:"使用的大模型驱动,固定为 openai" json:"llm_driver,omitempty" choices:"openai"`
|
||||
Model *string `help:"使用的模型名称" json:"model,omitempty"`
|
||||
McpServer *string `help:"mcp 服务器的后端地址" json:"mcp_server,omitempty"`
|
||||
AiProxyRoutingId *string `help:"关联的 AI 网关路由规则 ID" json:"aiproxy_routing_id,omitempty"`
|
||||
AiproxyVirtualKeyId *string `help:"关联的 AI 网关 API Key ID" json:"aiproxy_virtual_key_id,omitempty"`
|
||||
DefaultAgent *bool `help:"set as default MCP agent (only one can be true globally)" json:"default_agent,omitempty"`
|
||||
}
|
||||
|
||||
func (o *MCPAgentUpdateOptions) GetId() string {
|
||||
@@ -66,9 +66,6 @@ func (o *MCPAgentUpdateOptions) GetId() string {
|
||||
func (o *MCPAgentUpdateOptions) Params() (jsonutils.JSONObject, error) {
|
||||
// 只包含非空字段
|
||||
params := jsonutils.NewDict()
|
||||
if o.LlmId != nil && len(*o.LlmId) > 0 {
|
||||
params.Set("llm_id", jsonutils.NewString(*o.LlmId))
|
||||
}
|
||||
if o.LlmUrl != nil && len(*o.LlmUrl) > 0 {
|
||||
params.Set("llm_url", jsonutils.NewString(*o.LlmUrl))
|
||||
}
|
||||
@@ -78,12 +75,15 @@ func (o *MCPAgentUpdateOptions) Params() (jsonutils.JSONObject, error) {
|
||||
if o.Model != nil && len(*o.Model) > 0 {
|
||||
params.Set("model", jsonutils.NewString(*o.Model))
|
||||
}
|
||||
if o.ApiKey != nil && len(*o.ApiKey) > 0 {
|
||||
params.Set("api_key", jsonutils.NewString(*o.ApiKey))
|
||||
}
|
||||
if o.McpServer != nil && len(*o.McpServer) > 0 {
|
||||
params.Set("mcp_server", jsonutils.NewString(*o.McpServer))
|
||||
}
|
||||
if o.AiProxyRoutingId != nil && len(*o.AiProxyRoutingId) > 0 {
|
||||
params.Set("aiproxy_routing_id", jsonutils.NewString(*o.AiProxyRoutingId))
|
||||
}
|
||||
if o.AiproxyVirtualKeyId != nil && len(*o.AiproxyVirtualKeyId) > 0 {
|
||||
params.Set("aiproxy_virtual_key_id", jsonutils.NewString(*o.AiproxyVirtualKeyId))
|
||||
}
|
||||
if o.DefaultAgent != nil {
|
||||
params.Set("default_agent", jsonutils.NewBool(*o.DefaultAgent))
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnsupportedFormat = errors.Error("unsupported format")
|
||||
ErrUnsupportedFormat = errors.Error("unsupported format")
|
||||
ErrBackingFileNotAllowed = errors.Error("image backing file is not allowed")
|
||||
|
||||
convertWorkInOrder = false
|
||||
convertCoroutines = 16
|
||||
@@ -230,7 +231,11 @@ func (img *SQemuImage) parse() error {
|
||||
img.ClusterSize = info.ClusterSize
|
||||
img.Compat = info.FormatSpecific.Data.Compat
|
||||
img.Encrypted = info.Encrypted
|
||||
img.BackFilePath, err = ParseQemuFilepath(info.FullBackingFilename)
|
||||
backing := info.FullBackingFilename
|
||||
if backing == "" {
|
||||
backing = info.BackingFilename
|
||||
}
|
||||
img.BackFilePath, err = ParseQemuFilepath(backing)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "ParseQemuFilepath")
|
||||
}
|
||||
@@ -267,6 +272,13 @@ func (img *SQemuImage) IsChained() bool {
|
||||
return len(img.BackFilePath) > 0
|
||||
}
|
||||
|
||||
func (img *SQemuImage) CheckNoBackingFile() error {
|
||||
if img.IsChained() {
|
||||
return ErrBackingFileNotAllowed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (img *SQemuImage) GetBackingChain() ([]string, error) {
|
||||
if len(img.BackFilePath) > 0 {
|
||||
backImg, err := NewQemuImage(img.BackFilePath)
|
||||
|
||||
@@ -184,3 +184,14 @@ func TestParseBackingFile(t *testing.T) {
|
||||
t.Errorf("want: %s got: %s", want, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNoBackingFile(t *testing.T) {
|
||||
img := &SQemuImage{}
|
||||
if err := img.CheckNoBackingFile(); err != nil {
|
||||
t.Fatalf("standalone image: %v", err)
|
||||
}
|
||||
img.BackFilePath = "nbd://127.0.0.1:10809/disk"
|
||||
if err := img.CheckNoBackingFile(); err == nil {
|
||||
t.Fatal("expected error when backing file is set")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,11 +540,10 @@ func generateDhcpOptions(ctx context.Context, guestnetwork *agentmodels.Guestnet
|
||||
mdIp, "0.0.0.0",
|
||||
"0.0.0.0/0", network.GuestGateway,
|
||||
)
|
||||
} else {
|
||||
routes = append(routes,
|
||||
cidr, "0.0.0.0",
|
||||
)
|
||||
}
|
||||
routes = append(routes,
|
||||
cidr, "0.0.0.0",
|
||||
)
|
||||
if len(routes) > 0 {
|
||||
dhcpopts.Options["classless_static_route"] = fmt.Sprintf("{%s}", strings.Join(routes, ","))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user