diff --git a/pkg/apis/container.go b/pkg/apis/container.go index a9c6875bfd..21dbaed2cb 100644 --- a/pkg/apis/container.go +++ b/pkg/apis/container.go @@ -22,8 +22,18 @@ import ( ) type ContainerKeyValue struct { - Key string `json:"key"` - Value string `json:"value"` + Key string `json:"key"` + Value string `json:"value"` + ValueFrom *ContainerValueSource `json:"value_from"` +} + +type ContainerValueSource struct { + Credential *ContainerValueSourceCredential `json:"credential"` +} + +type ContainerValueSourceCredential struct { + Id string `json:"id"` + Key string `json:"key"` } type ContainerLifecyleHandlerType string diff --git a/pkg/apis/host/container.go b/pkg/apis/host/container.go index 840cb15796..f361a4d1d7 100644 --- a/pkg/apis/host/container.go +++ b/pkg/apis/host/container.go @@ -73,6 +73,7 @@ type ContainerVolumeMount struct { type ContainerSpec struct { apis.ContainerSpec ImageCredentialToken string `json:"image_credential_token"` + SecretCredentials map[string]string `json:"secret_credentials"` Rootfs *ContainerRootfs `json:"rootfs"` VolumeMounts []*ContainerVolumeMount `json:"volume_mounts"` Devices []*ContainerDevice `json:"devices"` diff --git a/pkg/apis/identity/aksk.go b/pkg/apis/identity/aksk.go index 8111b98a13..6f5959f6d8 100644 --- a/pkg/apis/identity/aksk.go +++ b/pkg/apis/identity/aksk.go @@ -27,6 +27,7 @@ const ( OIDC_CREDENTIAL_TYPE = "oidc" ENCRYPT_KEY_TYPE = "enc_key" CONTAINER_IMAGE_TYPE = "container_image" + CONTAINER_SECRET_TYPE = "container_secret" ) type SAccessKeySecretBlob struct { diff --git a/pkg/apis/identity/credential.go b/pkg/apis/identity/credential.go index 668f3e8d42..dc88be1e40 100644 --- a/pkg/apis/identity/credential.go +++ b/pkg/apis/identity/credential.go @@ -31,6 +31,13 @@ type CredentialUpdateInput struct { // enabled Enabled *bool `json:"enabled"` + + // 更新 blob 内容(明文,服务端加密后写入 encrypted_blob) + Blob string `json:"blob"` + + // 由 ValidateUpdateData 在提供 blob 时填充,不允许客户端直接传入 + EncryptedBlob string `json:"encrypted_blob"` + KeyHash string `json:"key_hash"` } type CredentialCreateInput struct { diff --git a/pkg/apis/llm/image.go b/pkg/apis/llm/image.go index f5bda4cd0a..cff43c640d 100644 --- a/pkg/apis/llm/image.go +++ b/pkg/apis/llm/image.go @@ -9,14 +9,19 @@ import ( type LLMImageType string const ( - LLM_IMAGE_TYPE_OLLAMA LLMImageType = "ollama" - LLM_IMAGE_TYPE_DIFY LLMImageType = "dify" + LLM_IMAGE_TYPE_OLLAMA LLMImageType = "ollama" + LLM_IMAGE_TYPE_VLLM LLMImageType = "vllm" + LLM_IMAGE_TYPE_DIFY LLMImageType = "dify" + LLM_IMAGE_TYPE_COMFYUI LLMImageType = "comfyui" + LLM_IMAGE_TYPE_OPENCLAW LLMImageType = "openclaw" ) var ( LLM_IMAGE_TYPES = sets.NewString( string(LLM_IMAGE_TYPE_OLLAMA), string(LLM_IMAGE_TYPE_DIFY), + string(LLM_IMAGE_TYPE_COMFYUI), + string(LLM_IMAGE_TYPE_OPENCLAW), ) ) diff --git a/pkg/apis/llm/llm.go b/pkg/apis/llm/llm.go index edb523ed6b..4b16877da3 100644 --- a/pkg/apis/llm/llm.go +++ b/pkg/apis/llm/llm.go @@ -55,7 +55,8 @@ type MountedModelInfo struct { type LLMListDetails struct { LLMBaseListDetails - LLMSku string `json:"llm_sku"` + LLMSku string `json:"llm_sku"` + LLMType string `json:"llm_type"` MountedModels []MountedModelInfo `json:"mounted_models"` } @@ -76,8 +77,9 @@ type LLMBaseCreateInput struct { type LLMCreateInput struct { LLMBaseCreateInput - LLMSkuId string `json:"llm_sku_id"` - LLMImageId string `json:"llm_image_id"` + LLMSkuId string `json:"llm_sku_id"` + LLMImageId string `json:"llm_image_id"` + LLMSpec *LLMSpec `json:"llm_spec,omitempty"` } type LLMBaseListInput struct { diff --git a/pkg/apis/llm/llm_container.go b/pkg/apis/llm/llm_container.go index 2caa2760c5..d2028e68a6 100644 --- a/pkg/apis/llm/llm_container.go +++ b/pkg/apis/llm/llm_container.go @@ -9,9 +9,11 @@ import ( type LLMContainerType string const ( - LLM_CONTAINER_OLLAMA LLMContainerType = "ollama" - LLM_CONTAINER_VLLM LLMContainerType = "vllm" - LLM_CONTAINER_DIFY LLMContainerType = "dify" + LLM_CONTAINER_OLLAMA LLMContainerType = "ollama" + LLM_CONTAINER_VLLM LLMContainerType = "vllm" + LLM_CONTAINER_DIFY LLMContainerType = "dify" + LLM_CONTAINER_COMFYUI LLMContainerType = "comfyui" + LLM_CONTAINER_OPENCLAW LLMContainerType = "openclaw" ) var ( @@ -19,6 +21,8 @@ var ( string(LLM_CONTAINER_OLLAMA), string(LLM_CONTAINER_VLLM), string(LLM_CONTAINER_DIFY), + string(LLM_CONTAINER_COMFYUI), + string(LLM_CONTAINER_OPENCLAW), ) ) diff --git a/pkg/apis/llm/llm_spec.go b/pkg/apis/llm/llm_spec.go index 7e74d9a926..dd42e79774 100644 --- a/pkg/apis/llm/llm_spec.go +++ b/pkg/apis/llm/llm_spec.go @@ -15,7 +15,6 @@ package llm import ( - "encoding/json" "reflect" "yunion.io/x/jsonutils" @@ -24,9 +23,11 @@ import ( // LLMSpec is the flat spec for LLM SKU: optional ollama/vllm/dify payload. Type is on LLMSku.LLMType. type LLMSpec struct { - Ollama *LLMSpecOllama `json:"ollama,omitempty"` - Vllm *LLMSpecVllm `json:"vllm,omitempty"` - Dify *LLMSpecDify `json:"dify,omitempty"` + Ollama *LLMSpecOllama `json:"ollama,omitempty"` + Vllm *LLMSpecVllm `json:"vllm,omitempty"` + Dify *LLMSpecDify `json:"dify,omitempty"` + ComfyUI *LLMSpecComfyUI `json:"comfyui,omitempty"` + OpenClaw *LLMSpecOpenClaw `json:"openclaw,omitempty"` } func (s *LLMSpec) String() string { @@ -37,53 +38,11 @@ func (s *LLMSpec) IsZero() bool { if s == nil { return true } - return s.Ollama == nil && s.Vllm == nil && s.Dify == nil -} - -// UnmarshalJSON supports both new format (type + ollama/vllm/dify) and legacy format (type + data). -func (s *LLMSpec) UnmarshalJSON(data []byte) error { - var raw struct { - Type string `json:"type"` - Ollama *LLMSpecOllama `json:"ollama,omitempty"` - Vllm *LLMSpecVllm `json:"vllm,omitempty"` - Dify *LLMSpecDify `json:"dify,omitempty"` - Data json.RawMessage `json:"data,omitempty"` - } - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - s.Ollama = raw.Ollama - s.Vllm = raw.Vllm - s.Dify = raw.Dify - if len(raw.Data) > 0 && s.Ollama == nil && s.Vllm == nil && s.Dify == nil { - switch raw.Type { - case string(LLM_CONTAINER_OLLAMA): - s.Ollama = &LLMSpecOllama{} - if err := json.Unmarshal(raw.Data, s.Ollama); err != nil { - return err - } - case string(LLM_CONTAINER_VLLM): - s.Vllm = &LLMSpecVllm{} - if err := json.Unmarshal(raw.Data, s.Vllm); err != nil { - return err - } - case string(LLM_CONTAINER_DIFY): - s.Dify = &LLMSpecDify{} - if err := json.Unmarshal(raw.Data, s.Dify); err != nil { - return err - } - default: - s.Ollama = &LLMSpecOllama{} - _ = json.Unmarshal(raw.Data, s.Ollama) - } - } - return nil + return s.Ollama == nil && s.Vllm == nil && s.Dify == nil && s.ComfyUI == nil && s.OpenClaw == nil } // LLMSpecOllama holds type-specific fields for ollama SKUs. type LLMSpecOllama struct { - LLMImageId string `json:"llm_image_id"` - MountedModels []string `json:"mounted_models"` } func (s *LLMSpecOllama) String() string { @@ -94,14 +53,12 @@ func (s *LLMSpecOllama) IsZero() bool { if s == nil { return true } - return s.LLMImageId == "" && len(s.MountedModels) == 0 + return false } // LLMSpecVllm holds type-specific fields for vllm SKUs (includes PreferredModel). type LLMSpecVllm struct { - LLMImageId string `json:"llm_image_id"` - MountedModels []string `json:"mounted_models"` - PreferredModel string `json:"preferred_model"` + PreferredModel string `json:"preferred_model"` } func (s *LLMSpecVllm) String() string { @@ -112,7 +69,7 @@ func (s *LLMSpecVllm) IsZero() bool { if s == nil { return true } - return s.LLMImageId == "" && len(s.MountedModels) == 0 && s.PreferredModel == "" + return s.PreferredModel == "" } // LLMSpecDify holds type-specific fields for Dify SKUs (multiple image ids + customized envs). @@ -143,6 +100,47 @@ func (s *LLMSpecDify) IsZero() bool { len(s.CustomizedEnvs) == 0 } +type LLMSpecComfyUI struct { +} + +func (s *LLMSpecComfyUI) String() string { + return jsonutils.Marshal(s).String() +} + +func (s *LLMSpecComfyUI) IsZero() bool { + if s == nil { + return true + } + return false +} + +type LLMSpecCredential struct { + Id string `json:"id"` + ExportKeys []string `json:"export_keys"` +} + +type LLMSpecOpenClawProvider struct { + Name string `json:"name"` + Credential *LLMSpecCredential `json:"credential"` +} + +type LLMSpecOpenClawChannel struct { + Name string `json:"name"` + Credential *LLMSpecCredential `json:"credential"` +} + +type LLMSpecOpenClaw struct { + Providers []*LLMSpecOpenClawProvider `json:"providers"` + Channels []*LLMSpecOpenClawChannel `json:"channels"` + WorkspaceTemplates *LLMSpecOpenClawWorkspaceTemplates `json:"workspace_templates"` +} + +type LLMSpecOpenClawWorkspaceTemplates struct { + AgentsMD string `json:"agents_md"` + SoulMD string `json:"soul_md"` + UserMD string `json:"user_md"` +} + func init() { gotypes.RegisterSerializable(reflect.TypeOf(new(LLMSpec)), func() gotypes.ISerializable { return new(LLMSpec) diff --git a/pkg/compute/models/containers.go b/pkg/compute/models/containers.go index 59d10f7a02..19cf02edd7 100644 --- a/pkg/compute/models/containers.go +++ b/pkg/compute/models/containers.go @@ -178,6 +178,9 @@ func (m *SContainerManager) ValidateSpec(ctx context.Context, userCred mcclient. return errors.Wrapf(err, "get image credential by id: %s", spec.ImageCredentialId) } } + if err := m.ValidateSpecEnvs(ctx, userCred, spec); err != nil { + return errors.Wrap(err, "validate envs") + } if pod != nil { if err := m.ValidateSpecRootFs(ctx, userCred, pod, spec, ctr); err != nil { @@ -231,6 +234,35 @@ func (m *SContainerManager) ValidateSpec(ctx context.Context, userCred mcclient. return nil } +func (m *SContainerManager) ValidateSpecEnvs(ctx context.Context, userCred mcclient.TokenCredential, spec *api.ContainerSpec) error { + var errs []error + for _, env := range spec.Envs { + if env.ValueFrom == nil { + continue + } + if env.ValueFrom.Credential != nil { + credId := env.ValueFrom.Credential.Id + if credId == "" { + errs = append(errs, errors.Wrapf(errors.ErrEmpty, "credential id is empty")) + continue + } + credKey := env.ValueFrom.Credential.Key + if credKey == "" { + errs = append(errs, errors.Wrapf(errors.ErrEmpty, "credential key is empty")) + continue + } + cred, err := m.GetSecretCredential(ctx, userCred, credId) + if err != nil { + errs = append(errs, errors.Wrapf(err, "get secret credential %s", credId)) + } + _, ok := cred[credKey] + if !ok { + errs = append(errs, errors.Wrapf(errors.ErrNotFound, "env %s secret credential %s key %s not found", env.Key, credId, credKey)) + } + } + } + return errors.NewAggregate(errs) +} func (m *SContainerManager) ValidateSpecLifecycle(ctx context.Context, cred mcclient.TokenCredential, spec *api.ContainerSpec) error { if spec.Lifecyle == nil { return nil @@ -738,20 +770,24 @@ func (c *SContainer) StartDeleteTask(ctx context.Context, userCred mcclient.Toke return task.ScheduleRun(nil) } -func (m *SContainerManager) GetImageCredential(ctx context.Context, userCred mcclient.TokenCredential, id string) (*apis.ContainerPullImageAuthConfig, error) { +func (m *SContainerManager) getKeystoneCredential(ctx context.Context, userCred mcclient.TokenCredential, id string) (jsonutils.JSONObject, error) { s := auth.GetSession(ctx, userCred, options.Options.Region) - ret, err := identitymod.Credentials.GetById(s, id, nil) - if err != nil { - if errors.Cause(err) == errors.ErrNotFound || strings.Contains(err.Error(), "NotFound") { - ret, err = identitymod.Credentials.GetByName(s, id, nil) - if err != nil { - return nil, errors.Wrapf(err, "get credential by id or name of %s", id) - } + if cred, err := identitymod.Credentials.GetById(s, id, nil); err == nil { + return cred, nil + } else if errors.Cause(err) == errors.ErrNotFound || strings.Contains(err.Error(), "NotFound") { + cred2, err2 := identitymod.Credentials.GetByName(s, id, nil) + if err2 != nil { + return nil, errors.Wrapf(err2, "get credential by id or name of %s", id) } + return cred2, nil + } else { return nil, errors.Wrapf(err, "get credentials by id with %s", userCred.String()) } +} + +func (m *SContainerManager) parseKeystoneCredentialBlob(ret jsonutils.JSONObject, expectedType string) (jsonutils.JSONObject, error) { credType, _ := ret.GetString("type") - if credType != identityapi.CONTAINER_IMAGE_TYPE { + if credType != expectedType { return nil, httperrors.NewNotSupportedError("unsupported credential type %s", credType) } blobStr, err := ret.GetString("blob") @@ -762,6 +798,18 @@ func (m *SContainerManager) GetImageCredential(ctx context.Context, userCred mcc if err != nil { return nil, errors.Wrapf(err, "json parse string: %s", blobStr) } + return obj, nil +} + +func (m *SContainerManager) GetImageCredential(ctx context.Context, userCred mcclient.TokenCredential, id string) (*apis.ContainerPullImageAuthConfig, error) { + ret, err := m.getKeystoneCredential(ctx, userCred, id) + if err != nil { + return nil, err + } + obj, err := m.parseKeystoneCredentialBlob(ret, identityapi.CONTAINER_IMAGE_TYPE) + if err != nil { + return nil, err + } blob := new(identityapi.CredentialContainerImageBlob) if err := obj.Unmarshal(blob); err != nil { return nil, errors.Wrap(err, "unmarshal blob") @@ -777,6 +825,22 @@ func (m *SContainerManager) GetImageCredential(ctx context.Context, userCred mcc return out, nil } +func (m *SContainerManager) GetSecretCredential(ctx context.Context, userCred mcclient.TokenCredential, id string) (map[string]string, error) { + ret, err := m.getKeystoneCredential(ctx, userCred, id) + if err != nil { + return nil, err + } + obj, err := m.parseKeystoneCredentialBlob(ret, identityapi.CONTAINER_SECRET_TYPE) + if err != nil { + return nil, err + } + out := map[string]string{} + if err := obj.Unmarshal(&out); err != nil { + return nil, errors.Wrap(err, "unmarshal blob") + } + return out, nil +} + func (c *SContainer) GetImageCredential(ctx context.Context, userCred mcclient.TokenCredential) (*apis.ContainerPullImageAuthConfig, error) { if c.Spec.ImageCredentialId == "" { return nil, errors.Wrap(errors.ErrEmpty, "image_credential_id is empty") @@ -799,6 +863,24 @@ func (c *SContainer) GetHostPullImageInput(ctx context.Context, userCred mcclien return input, nil } +func (c *SContainer) GetSecretCredentials(ctx context.Context, userCred mcclient.TokenCredential) (map[string]string, error) { + ret := make(map[string]string, 0) + for _, env := range c.Spec.Envs { + if env.ValueFrom == nil { + continue + } + if env.ValueFrom.Credential != nil { + credId := env.ValueFrom.Credential.Id + cred, err := GetContainerManager().GetSecretCredential(ctx, userCred, credId) + if err != nil { + return nil, errors.Wrapf(err, "GetSecretCredential %s", credId) + } + ret[credId] = base64.StdEncoding.EncodeToString([]byte(jsonutils.Marshal(cred).String())) + } + } + return ret, nil +} + func (c *SContainer) StartPullImageTask(ctx context.Context, userCred mcclient.TokenCredential, input *hostapi.ContainerPullImageInput, parentTaskId string) error { c.SetStatus(ctx, userCred, api.CONTAINER_STATUS_PULLING_IMAGE, "") task, err := taskman.TaskManager.NewTask(ctx, "ContainerPullImageTask", c, userCred, jsonutils.Marshal(input).(*jsonutils.JSONDict), parentTaskId, "", nil) @@ -876,6 +958,11 @@ func (c *SContainer) ToHostContainerSpec(ctx context.Context, userCred mcclient. return nil, errors.Wrap(err, "GetHostPullImageInput") } hSpec.ImageCredentialToken = base64.StdEncoding.EncodeToString([]byte(jsonutils.Marshal(pullInput.Auth).String())) + secretCredentials, err := c.GetSecretCredentials(ctx, userCred) + if err != nil { + return nil, errors.Wrap(err, "GetSecretCredentials") + } + hSpec.SecretCredentials = secretCredentials return hSpec, nil } diff --git a/pkg/compute/models/guest_actions.go b/pkg/compute/models/guest_actions.go index a8752134f7..58c62872e6 100644 --- a/pkg/compute/models/guest_actions.go +++ b/pkg/compute/models/guest_actions.go @@ -1880,15 +1880,24 @@ func (self *SGuest) StartDeleteGuestTask( ctx context.Context, userCred mcclient.TokenCredential, parentTaskId string, opts api.ServerDeleteInput, ) error { - driver, err := self.GetDriver() - if err != nil { - return errors.Wrapf(err, "GetDriver") - } params := jsonutils.NewDict() params.Add(jsonutils.NewString(self.Status), "guest_status") params.Update(jsonutils.Marshal(opts)) self.SetStatus(ctx, userCred, api.VM_START_DELETE, "") - return driver.StartDeleteGuestTask(ctx, userCred, self, params, parentTaskId) + if self.HostId != "" { + driver, err := self.GetDriver() + if err != nil { + return errors.Wrapf(err, "GetDriver") + } + return driver.StartDeleteGuestTask(ctx, userCred, self, params, parentTaskId) + } else { + task, err := taskman.TaskManager.NewTask(ctx, "GuestDeleteWithoutHostTask", self, userCred, params, parentTaskId, "", nil) + if err != nil { + return errors.Wrap(err, "NewTask GuestDeleteWithoutHostTask") + } + task.ScheduleRun(nil) + return nil + } } // 清除虚拟机记录(仅数据库操作) diff --git a/pkg/compute/models/guests.go b/pkg/compute/models/guests.go index 37aa290fb9..c4bbc4762e 100644 --- a/pkg/compute/models/guests.go +++ b/pkg/compute/models/guests.go @@ -5316,9 +5316,6 @@ func (self *SGuest) AllowDeleteItem(ctx context.Context, userCred mcclient.Token // 删除虚拟机 func (self *SGuest) CustomizeDelete(ctx context.Context, userCred mcclient.TokenCredential, query api.ServerDeleteInput, data jsonutils.JSONObject) error { - if len(self.HostId) == 0 { - return self.RealDelete(ctx, userCred) - } return self.StartDeleteGuestTask(ctx, userCred, "", query) } diff --git a/pkg/compute/tasks/container/container_delete_task.go b/pkg/compute/tasks/container/container_delete_task.go index c9a9dabe3b..83882ab50f 100644 --- a/pkg/compute/tasks/container/container_delete_task.go +++ b/pkg/compute/tasks/container/container_delete_task.go @@ -38,6 +38,12 @@ func init() { func (t *ContainerDeleteTask) OnInit(ctx context.Context, obj db.IStandaloneModel, body jsonutils.JSONObject) { t.SetStage("OnStopped", nil) ctr := obj.(*models.SContainer) + isPurge := jsonutils.QueryBoolean(t.GetParams(), "purge", false) + if isPurge { + // purge: skip host stop/delete, go directly to delete from DB + t.ScheduleRun(nil) + return + } if err := ctr.StartStopTask(ctx, t.GetUserCred(), &api.ContainerStopInput{}, t.GetTaskId()); err != nil { t.SetStageFailed(ctx, jsonutils.NewString(err.Error())) return diff --git a/pkg/compute/tasks/guest/guest_delete_without_host_task.go b/pkg/compute/tasks/guest/guest_delete_without_host_task.go new file mode 100644 index 0000000000..bd185ce04c --- /dev/null +++ b/pkg/compute/tasks/guest/guest_delete_without_host_task.go @@ -0,0 +1,41 @@ +// 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 guest + +import ( + "context" + + "yunion.io/x/jsonutils" + + "yunion.io/x/onecloud/pkg/cloudcommon/db" + "yunion.io/x/onecloud/pkg/cloudcommon/db/taskman" + "yunion.io/x/onecloud/pkg/compute/models" +) + +type GuestDeleteWithoutHostTask struct { + SGuestBaseTask +} + +func init() { + taskman.RegisterTask(GuestDeleteWithoutHostTask{}) +} + +func (t *GuestDeleteWithoutHostTask) OnInit(ctx context.Context, obj db.IStandaloneModel, body jsonutils.JSONObject) { + guest := obj.(*models.SGuest) + data := jsonutils.NewDict() + data.Set("real_delete", jsonutils.JSONTrue) + guest.FinalizeDeleteTask(ctx, t.GetUserCred(), t, data) + t.SetStageComplete(ctx, nil) +} diff --git a/pkg/hostman/guestman/pod.go b/pkg/hostman/guestman/pod.go index 3de923a3f6..a1f02db827 100644 --- a/pkg/hostman/guestman/pod.go +++ b/pkg/hostman/guestman/pod.go @@ -17,6 +17,7 @@ package guestman import ( "context" "encoding/base64" + "encoding/json" "fmt" "io" "io/ioutil" @@ -2084,6 +2085,39 @@ func (s *sPodGuestInstance) createContainer(ctx context.Context, userCred mcclie ctrCfg.Linux.SecurityContext.Capabilities.AddCapabilities = spec.Capabilities.Add ctrCfg.Linux.SecurityContext.Capabilities.DropCapabilities = spec.Capabilities.Drop } + + envSecs := make(map[string]map[string]string, 0) + if len(spec.SecretCredentials) > 0 { + for credId, credData := range spec.SecretCredentials { + obj := map[string]string{} + credKey, err := base64.StdEncoding.DecodeString(credData) + if err != nil { + return "", errors.Wrapf(err, "decode secret credential %s", credId) + } + if err := json.Unmarshal(credKey, &obj); err != nil { + return "", errors.Wrapf(err, "unmarshal secret credential %s", credId) + } + envSecs[credId] = obj + } + for i := range spec.Envs { + env := spec.Envs[i] + if env.ValueFrom == nil { + continue + } + if env.ValueFrom.Credential != nil { + credId := env.ValueFrom.Credential.Id + if _, ok := envSecs[credId]; !ok { + return "", errors.Wrapf(errors.ErrNotFound, "secret credential %s not found", credId) + } + credKey := env.ValueFrom.Credential.Key + if _, ok := envSecs[credId][credKey]; !ok { + return "", errors.Wrapf(errors.ErrNotFound, "secret credential %s key %s not found", credId, credKey) + } + env.Value = envSecs[credId][credKey] + } + } + } + for _, env := range spec.Envs { ctrCfg.Envs = append(ctrCfg.Envs, &runtimeapi.KeyValue{ Key: env.Key, diff --git a/pkg/keystone/models/credentials.go b/pkg/keystone/models/credentials.go index 35f3af03bf..cb21d14ca3 100644 --- a/pkg/keystone/models/credentials.go +++ b/pkg/keystone/models/credentials.go @@ -75,11 +75,11 @@ type SCredential struct { UserId string `width:"64" charset:"ascii" nullable:"false" list:"user" create:"required"` ProjectId string `width:"64" charset:"ascii" nullable:"true" list:"user" create:"required"` Type string `width:"255" charset:"utf8" nullable:"false" list:"user" create:"required"` - KeyHash string `width:"64" charset:"ascii" nullable:"false" create:"required"` + KeyHash string `width:"64" charset:"ascii" nullable:"false" create:"required" update:"user"` Extra *jsonutils.JSONDict `nullable:"true" list:"admin"` - EncryptedBlob string `nullable:"false" create:"required"` + EncryptedBlob string `nullable:"false" create:"required" update:"user"` Enabled tristate.TriState `default:"true" list:"user" update:"user" create:"optional"` } @@ -174,6 +174,18 @@ func (cred *SCredential) ValidateUpdateData(ctx context.Context, userCred mcclie return input, errors.Wrap(err, "SStandaloneResourceBase.ValidateUpdateData") } + if len(input.Blob) > 0 { + if cred.Type != api.CONTAINER_SECRET_TYPE { + return input, httperrors.NewNotSupportedError("blob update only supported for credential type %s", api.CONTAINER_SECRET_TYPE) + } + blobEnc, err := keys.CredentialKeyManager.Encrypt([]byte(input.Blob)) + if err != nil { + return input, httperrors.NewInternalServerError("encrypt blob: %s", err) + } + input.EncryptedBlob = string(blobEnc) + input.KeyHash = keys.CredentialKeyManager.PrimaryKeyHash() + } + return input, nil } diff --git a/pkg/llm/drivers/llm_container/base_driver.go b/pkg/llm/drivers/llm_container/base_driver.go new file mode 100644 index 0000000000..2653eec594 --- /dev/null +++ b/pkg/llm/drivers/llm_container/base_driver.go @@ -0,0 +1,96 @@ +package llm_container + +import ( + "context" + + "yunion.io/x/pkg/errors" + + api "yunion.io/x/onecloud/pkg/apis/llm" + "yunion.io/x/onecloud/pkg/cloudcommon/validators" + "yunion.io/x/onecloud/pkg/httperrors" + "yunion.io/x/onecloud/pkg/llm/models" + "yunion.io/x/onecloud/pkg/mcclient" +) + +type baseDriver struct { + drvType api.LLMContainerType +} + +func newBaseDriver(drvType api.LLMContainerType) baseDriver { + return baseDriver{drvType: drvType} +} + +func (b *baseDriver) GetType() api.LLMContainerType { + return b.drvType +} + +func (b *baseDriver) GetPrimaryImageId(sku *models.SLLMSku) string { + return sku.LLMImageId +} + +func (b *baseDriver) GetMountedModels(sku *models.SLLMSku) []string { + return sku.MountedModels +} + +func (b *baseDriver) StartLLM(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) error { + return nil +} + +func (b *baseDriver) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*api.LLMSkuCreateInput, error) { + imgObj, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), &input.LLMImageId) + if err != nil { + return nil, errors.Wrapf(err, "validate image_id %s", input.LLMImageId) + } + llmImage := imgObj.(*models.SLLMImage) + if llmImage.LLMType != input.LLMType { + return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", input.LLMImageId, input.LLMType) + } + input.LLMImageId = llmImage.Id + if input.MountedModels != nil { + for i, mdl := range input.MountedModels { + instMdl, err := models.GetInstantModelManager().FetchByIdOrName(ctx, userCred, mdl) + if err != nil { + return nil, errors.Wrapf(err, "validate mounted model %s", mdl) + } + instantModle := instMdl.(*models.SInstantModel) + if instantModle.LlmType != input.LLMType { + return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "mounted model %s is not of type %s", mdl, input.LLMType) + } + input.MountedModels[i] = instantModle.GetId() + } + } + return input, nil +} + +func (b *baseDriver) ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCredential, sku *models.SLLMSku, input *api.LLMSkuUpdateInput) (*api.LLMSkuUpdateInput, error) { + llmImageId := input.LLMImageId + if llmImageId != "" { + imgObj, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), &llmImageId) + if err != nil { + return nil, errors.Wrapf(err, "validate image_id %s", llmImageId) + } + llmImage := imgObj.(*models.SLLMImage) + if llmImage.LLMType != sku.LLMType { + return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", llmImageId, sku.LLMType) + } + input.LLMImageId = llmImage.Id + } + + mountedModels := input.MountedModels + if input.MountedModels != nil { + mountedModels = make([]string, len(input.MountedModels)) + for i, mdl := range input.MountedModels { + instMdl, err := models.GetInstantModelManager().FetchByIdOrName(ctx, userCred, mdl) + if err != nil { + return nil, errors.Wrapf(err, "validate mounted model %s", mdl) + } + instantModle := instMdl.(*models.SInstantModel) + if instantModle.LlmType != sku.LLMType { + return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "mounted model %s is not of type %s", mdl, sku.LLMType) + } + mountedModels[i] = instantModle.GetId() + } + } + input.MountedModels = mountedModels + return input, nil +} diff --git a/pkg/llm/drivers/llm_container/comfyui.go b/pkg/llm/drivers/llm_container/comfyui.go new file mode 100644 index 0000000000..e224f1398a --- /dev/null +++ b/pkg/llm/drivers/llm_container/comfyui.go @@ -0,0 +1,213 @@ +package llm_container + +import ( + "context" + "fmt" + "strings" + + "yunion.io/x/pkg/errors" + + commonapi "yunion.io/x/onecloud/pkg/apis" + computeapi "yunion.io/x/onecloud/pkg/apis/compute" + api "yunion.io/x/onecloud/pkg/apis/llm" + "yunion.io/x/onecloud/pkg/llm/models" + "yunion.io/x/onecloud/pkg/mcclient" +) + +func init() { + models.RegisterLLMContainerDriver(newComfyUI()) +} + +type comfyui struct { + baseDriver +} + +func newComfyUI() models.ILLMContainerDriver { + return &comfyui{baseDriver: newBaseDriver(api.LLM_CONTAINER_COMFYUI)} +} + +func (c *comfyui) GetSpec(sku *models.SLLMSku) interface{} { + if sku.LLMSpec == nil { + return nil + } + return sku.LLMSpec.ComfyUI +} + +func (c *comfyui) GetEffectiveSpec(llm *models.SLLM, sku *models.SLLMSku) interface{} { + if llm != nil && llm.LLMSpec != nil && llm.LLMSpec.ComfyUI != nil { + return llm.LLMSpec.ComfyUI + } + return c.GetSpec(sku) +} + +func (c *comfyui) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput { + spec := computeapi.ContainerSpec{ + ContainerSpec: commonapi.ContainerSpec{ + Image: image.ToContainerImage(), + ImageCredentialId: image.CredentialId, + EnableLxcfs: true, + AlwaysRestart: true, + Envs: []*commonapi.ContainerKeyValue{ + { + Key: "CLI_ARGS", + Value: "--disable-xformers", + }, + }, + }, + } + + if len(devices) == 0 && (sku.Devices != nil && len(*sku.Devices) > 0) { + for i := range *sku.Devices { + index := i + spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{ + Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE, + IsolatedDevice: &computeapi.ContainerIsolatedDevice{ + Index: &index, + }, + }) + } + } else if len(devices) > 0 { + for i := range devices { + spec.Devices = append(spec.Devices, &computeapi.ContainerDevice{ + Type: commonapi.CONTAINER_DEVICE_TYPE_ISOLATED_DEVICE, + IsolatedDevice: &computeapi.ContainerIsolatedDevice{ + Id: devices[i].Id, + }, + }) + } + } + + // Volume Mounts, see: https://github.com/YanWenKun/ComfyUI-Docker?tab=readme-ov-file#quick-start---nvidia-gpu + diskIndex := 0 + ctrVols := []*commonapi.ContainerVolumeMount{ + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root", + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage-models/models", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root/ComfyUI/models", + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage-models/hf-hub", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root/.cache/huggingface/hub", + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage-models/torch-hub", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root/.cache/torch/hub", + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage-user/input", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root/ComfyUI/input", + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage-user/output", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root/ComfyUI/output", + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "storage-user/workflows", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: "/root/ComfyUI/user/default/workflows", + }, + } + spec.VolumeMounts = append(spec.VolumeMounts, ctrVols...) + + return &computeapi.PodContainerCreateInput{ + ContainerSpec: spec, + } +} + +func (c *comfyui) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { + return []*computeapi.PodContainerCreateInput{ + c.GetContainerSpec(ctx, llm, image, sku, props, devices, diskId), + } +} + +func (c *comfyui) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) (string, error) { + server, err := llm.GetServer(ctx) + if err != nil { + return "", errors.Wrap(err, "get server") + } + // 从 IPs 字符串中选择第一个 IP + ips := strings.Split(strings.TrimSpace(server.IPs), ",") + if len(ips) == 0 || len(strings.TrimSpace(ips[0])) == 0 { + return "", errors.Error("server IPs is empty") + } + firstIP := strings.TrimSpace(ips[0]) + return fmt.Sprintf("http://%s:%d", firstIP, 8188), nil +} + +func (c *comfyui) GetProbedInstantModelsExt(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, mdlIds ...string) (map[string]api.LLMInternalInstantMdlInfo, error) { + return nil, nil +} + +func (c *comfyui) DetectModelPaths(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, pkgInfo api.LLMInternalInstantMdlInfo) ([]string, error) { + return nil, nil +} + +func (c *comfyui) GetImageInternalPathMounts(sApp *models.SInstantModel) map[string]string { + return nil +} + +func (c *comfyui) GetSaveDirectories(sApp *models.SInstantModel) (string, []string, error) { + return "", nil, nil +} + +func (c *comfyui) ValidateMounts(mounts []string, mdlName string, mdlTag string) ([]string, error) { + return nil, nil +} + +func (c *comfyui) CheckDuplicateMounts(errStr string, dupIndex int) string { + return "Duplicate mounts detected" +} + +func (c *comfyui) GetInstantModelIdByPostOverlay(postOverlay *commonapi.ContainerVolumeMountDiskPostOverlay, mdlNameToId map[string]string) string { + return "" +} + +func (c *comfyui) GetDirPostOverlay(dir api.LLMMountDirInfo) *commonapi.ContainerVolumeMountDiskPostOverlay { + return nil +} + +func (c *comfyui) PreInstallModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, instMdl *models.SLLMInstantModel) error { + return nil +} + +func (c *comfyui) InstallModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, dirs []string, mdlIds []string) error { + return nil +} + +func (c *comfyui) UninstallModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, instMdl *models.SLLMInstantModel) error { + return nil +} + +func (c *comfyui) DownloadModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, tmpDir string, modelName string, modelTag string) (string, []string, error) { + return "", nil, nil +} diff --git a/pkg/llm/drivers/llm_container/dify.go b/pkg/llm/drivers/llm_container/dify.go index d61f635cc5..592e226208 100644 --- a/pkg/llm/drivers/llm_container/dify.go +++ b/pkg/llm/drivers/llm_container/dify.go @@ -36,6 +36,38 @@ func (d *dify) GetSpec(sku *models.SLLMSku) interface{} { return sku.LLMSpec.Dify } +// mergeDify merges llm and sku Dify specs; llm takes priority, use sku when llm is nil or zero. +func mergeDify(llm, sku *api.LLMSpecDify) *api.LLMSpecDify { + if llm != nil && !llm.IsZero() { + out := *llm + if llm.CustomizedEnvs != nil { + out.CustomizedEnvs = make([]*api.DifyCustomizedEnv, len(llm.CustomizedEnvs)) + copy(out.CustomizedEnvs, llm.CustomizedEnvs) + } + return &out + } + if sku != nil { + out := *sku + if sku.CustomizedEnvs != nil { + out.CustomizedEnvs = make([]*api.DifyCustomizedEnv, len(sku.CustomizedEnvs)) + copy(out.CustomizedEnvs, sku.CustomizedEnvs) + } + return &out + } + return nil +} + +func (d *dify) GetEffectiveSpec(llm *models.SLLM, sku *models.SLLMSku) interface{} { + if sku == nil || sku.LLMSpec == nil { + return nil + } + var llmDify *api.LLMSpecDify + if llm != nil && llm.LLMSpec != nil { + llmDify = llm.LLMSpec.Dify + } + return mergeDify(llmDify, sku.LLMSpec.Dify) +} + func (d *dify) GetPrimaryImageId(sku *models.SLLMSku) string { if spec := d.GetSpec(sku); spec != nil { s := spec.(*api.LLMSpecDify) @@ -46,24 +78,36 @@ func (d *dify) GetPrimaryImageId(sku *models.SLLMSku) string { return "" } -func (d *dify) ValidateCreateSpec(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*api.LLMSpec, error) { +func (d *dify) ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*api.LLMSkuCreateInput, error) { if input.LLMSpec == nil || input.LLMSpec.Dify == nil { return nil, errors.Wrap(httperrors.ErrInputParameter, "dify SKU requires llm_spec with type dify and image ids") } + if input.MountedModels != nil { + return nil, errors.Wrap(httperrors.ErrInputParameter, "dify SKU does not support mounted models") + } difySpec := input.LLMSpec.Dify for _, imgId := range []*string{&difySpec.PostgresImageId, &difySpec.RedisImageId, &difySpec.NginxImageId, &difySpec.DifyApiImageId, &difySpec.DifyPluginImageId, &difySpec.DifyWebImageId, &difySpec.DifySandboxImageId, &difySpec.DifySSRFImageId, &difySpec.DifyWeaviateImageId} { if *imgId == "" { continue } - _, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), imgId) + imgObj, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), imgId) if err != nil { return nil, errors.Wrapf(err, "validate image_id %s", *imgId) } + img := imgObj.(*models.SLLMImage) + if img.LLMType != input.LLMType { + return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", *imgId, input.LLMType) + } + *imgId = img.Id } - return input.LLMSpec, nil + input.LLMImageId = difySpec.DifyApiImageId + return input, nil } -func (d *dify) ValidateUpdateSpec(ctx context.Context, userCred mcclient.TokenCredential, sku *models.SLLMSku, input *api.LLMSkuUpdateInput) (*api.LLMSpec, error) { +func (d *dify) ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCredential, sku *models.SLLMSku, input *api.LLMSkuUpdateInput) (*api.LLMSkuUpdateInput, error) { + if input.MountedModels != nil { + return nil, errors.Wrap(httperrors.ErrInputParameter, "dify SKU does not support mounted models") + } if input.LLMSpec == nil || input.LLMSpec.Dify == nil { return nil, nil } @@ -92,13 +136,22 @@ func (d *dify) ValidateUpdateSpec(ctx context.Context, userCred mcclient.TokenCr } for _, imgId := range []*string{&updated.PostgresImageId, &updated.RedisImageId, &updated.NginxImageId, &updated.DifyApiImageId, &updated.DifyPluginImageId, &updated.DifyWebImageId, &updated.DifySandboxImageId, &updated.DifySSRFImageId, &updated.DifyWeaviateImageId} { if *imgId != "" { - _, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), imgId) + imgObj, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), imgId) if err != nil { return nil, errors.Wrapf(err, "validate image_id %s", *imgId) } + img := imgObj.(*models.SLLMImage) + if img.LLMType != sku.LLMType { + return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", *imgId, sku.LLMType) + } + *imgId = img.GetId() } } - return &api.LLMSpec{Ollama: nil, Vllm: nil, Dify: &updated}, nil + // if dify_api_image_id is set, use it as the primary image id + if input.LLMSpec.Dify.DifyApiImageId != "" { + input.LLMImageId = input.LLMSpec.Dify.DifyApiImageId + } + return input, nil } // GetContainerSpec is required by ILLMContainerDriver but not used for Dify; pod creation uses GetContainerSpecs. Return the first container so the interface is satisfied. @@ -110,9 +163,13 @@ func (d *dify) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *mo return specs[0] } -// GetContainerSpecs returns all Dify pod containers (postgres, redis, api, worker, nginx, etc.). SKU-only policy: customized envs come from llm_spec.dify.customized_envs. +// GetContainerSpecs returns all Dify pod containers (postgres, redis, api, worker, nginx, etc.). Uses effective spec (llm + sku merged by driver). func (d *dify) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { - return models.GetDifyContainersByNameAndSku(llm.GetName(), sku, nil) + spec := d.GetEffectiveSpec(llm, sku) + if spec == nil { + return nil + } + return models.GetDifyContainersByNameAndSku(llm.GetName(), sku, nil, spec.(*api.LLMSpecDify)) } // StartLLM is a no-op for Dify; all services are started by their container entrypoints. diff --git a/pkg/llm/drivers/llm_container/ollama.go b/pkg/llm/drivers/llm_container/ollama.go index a2d5a5c86c..1752158014 100644 --- a/pkg/llm/drivers/llm_container/ollama.go +++ b/pkg/llm/drivers/llm_container/ollama.go @@ -18,8 +18,6 @@ import ( commonapi "yunion.io/x/onecloud/pkg/apis" computeapi "yunion.io/x/onecloud/pkg/apis/compute" api "yunion.io/x/onecloud/pkg/apis/llm" - "yunion.io/x/onecloud/pkg/cloudcommon/validators" - "yunion.io/x/onecloud/pkg/httperrors" "yunion.io/x/onecloud/pkg/llm/models" llmutil "yunion.io/x/onecloud/pkg/llm/utils" "yunion.io/x/onecloud/pkg/mcclient" @@ -30,14 +28,12 @@ func init() { // log.Infoln("registed ollama") } -type ollama struct{} - -func newOllama() models.ILLMContainerDriver { - return new(ollama) +type ollama struct { + baseDriver } -func (o *ollama) GetType() api.LLMContainerType { - return api.LLM_CONTAINER_OLLAMA +func newOllama() models.ILLMContainerDriver { + return &ollama{baseDriver: newBaseDriver(api.LLM_CONTAINER_OLLAMA)} } func (o *ollama) GetSpec(sku *models.SLLMSku) interface{} { @@ -47,89 +43,11 @@ func (o *ollama) GetSpec(sku *models.SLLMSku) interface{} { return sku.LLMSpec.Ollama } -func (o *ollama) GetPrimaryImageId(sku *models.SLLMSku) string { - if spec := o.GetSpec(sku); spec != nil { - return spec.(*api.LLMSpecOllama).LLMImageId +func (o *ollama) GetEffectiveSpec(llm *models.SLLM, sku *models.SLLMSku) interface{} { + if llm != nil && llm.LLMSpec != nil && llm.LLMSpec.Ollama != nil { + return llm.LLMSpec.Ollama } - return "" -} - -func (o *ollama) GetMountedModels(sku *models.SLLMSku) []string { - if spec := o.GetSpec(sku); spec != nil { - return spec.(*api.LLMSpecOllama).MountedModels - } - return nil -} - -func (o *ollama) ValidateCreateSpec(ctx context.Context, userCred mcclient.TokenCredential, input *api.LLMSkuCreateInput) (*api.LLMSpec, error) { - imgObj, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), &input.LLMImageId) - if err != nil { - return nil, errors.Wrapf(err, "validate image_id %s", input.LLMImageId) - } - llmImage := imgObj.(*models.SLLMImage) - if llmImage.LLMType != input.LLMType { - return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", input.LLMImageId, input.LLMType) - } - input.LLMImageId = llmImage.Id - if input.MountedModels != nil { - for i, mdl := range input.MountedModels { - instMdl, err := models.GetInstantModelManager().FetchByIdOrName(ctx, userCred, mdl) - if err != nil { - return nil, errors.Wrapf(err, "validate mounted model %s", mdl) - } - instantModle := instMdl.(*models.SInstantModel) - if instantModle.LlmType != input.LLMType { - return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "mounted model %s is not of type %s", mdl, input.LLMType) - } - input.MountedModels[i] = instantModle.GetId() - } - } - return &api.LLMSpec{ - Ollama: &api.LLMSpecOllama{LLMImageId: input.LLMImageId, MountedModels: input.MountedModels}, - Vllm: nil, - Dify: nil, - }, nil -} - -func (o *ollama) ValidateUpdateSpec(ctx context.Context, userCred mcclient.TokenCredential, sku *models.SLLMSku, input *api.LLMSkuUpdateInput) (*api.LLMSpec, error) { - cur := o.GetSpec(sku) - if cur == nil { - return nil, nil - } - curSpec := cur.(*api.LLMSpecOllama) - llmImageId := curSpec.LLMImageId - mountedModels := curSpec.MountedModels - if input.LLMImageId != "" { - imgObj, err := validators.ValidateModel(ctx, userCred, models.GetLLMImageManager(), &input.LLMImageId) - if err != nil { - return nil, errors.Wrapf(err, "validate image_id %s", input.LLMImageId) - } - llmImage := imgObj.(*models.SLLMImage) - if llmImage.LLMType != sku.LLMType { - return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "image %s is not of type %s", input.LLMImageId, sku.LLMType) - } - llmImageId = llmImage.Id - } - if input.MountedModels != nil { - mountedModels = make([]string, len(input.MountedModels)) - for i, mdl := range input.MountedModels { - instMdl, err := models.GetInstantModelManager().FetchByIdOrName(ctx, userCred, mdl) - if err != nil { - return nil, errors.Wrapf(err, "validate mounted model %s", mdl) - } - instantModle := instMdl.(*models.SInstantModel) - if instantModle.LlmType != sku.LLMType { - return nil, errors.Wrapf(httperrors.ErrInvalidStatus, "mounted model %s is not of type %s", mdl, sku.LLMType) - } - mountedModels[i] = instantModle.GetId() - } - } - input.MountedModels = mountedModels - return &api.LLMSpec{ - Ollama: &api.LLMSpecOllama{LLMImageId: llmImageId, MountedModels: mountedModels}, - Vllm: nil, - Dify: nil, - }, nil + return o.GetSpec(sku) } func (o *ollama) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput { @@ -217,6 +135,12 @@ func (o *ollama) GetContainerSpec(ctx context.Context, llm *models.SLLM, image * } } +func (o *ollama) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { + return []*computeapi.PodContainerCreateInput{ + o.GetContainerSpec(ctx, llm, image, sku, props, devices, diskId), + } +} + // func (o *ollama) PullModelByInstall(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, modelName string, modelTag string) error { // return nil // } @@ -558,10 +482,6 @@ func (o *ollama) CheckDuplicateMounts(errStr string, dupIndex int) string { return fmt.Sprintf("Model %s and %s have duplicated container target dirs", firstModel, secondModel) } -func (o *ollama) StartLLM(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) error { - return nil -} - // func download(ctx context.Context, userCred mcclient.TokenCredential, containerId string, taskId string, webUrl string, path string) error { // input := &computeapi.ContainerDownloadFileInput{ // WebUrl: webUrl, diff --git a/pkg/llm/drivers/llm_container/openclaw.go b/pkg/llm/drivers/llm_container/openclaw.go new file mode 100644 index 0000000000..2e523f12f1 --- /dev/null +++ b/pkg/llm/drivers/llm_container/openclaw.go @@ -0,0 +1,390 @@ +package llm_container + +import ( + "context" + "fmt" + "strings" + + "yunion.io/x/jsonutils" + "yunion.io/x/log" + "yunion.io/x/pkg/errors" + + commonapi "yunion.io/x/onecloud/pkg/apis" + computeapi "yunion.io/x/onecloud/pkg/apis/compute" + api "yunion.io/x/onecloud/pkg/apis/llm" + "yunion.io/x/onecloud/pkg/llm/models" + "yunion.io/x/onecloud/pkg/mcclient" +) + +// coollabsio/openclaw docker-compose: openclaw (main) + browser (CDP sidecar for /browser/) +// See: https://github.com/coollabsio/openclaw/blob/main/docker-compose.yml +const ( + // openclawContainerName = "openclaw" + // browserContainerName = "browser" + // openclawBrowserImage = "registry.cn-beijing.aliyuncs.com/cloudpods/openclaw-browser:latest" + openclawDataDir = "/data" + browserConfigDir = "/config" + // openclawBrowserCDPPort = "9222" +) + +func appendCredentialEnvs(envs []*commonapi.ContainerKeyValue, cred *api.LLMSpecCredential) []*commonapi.ContainerKeyValue { + if cred == nil { + return envs + } + for _, key := range cred.ExportKeys { + envs = append(envs, &commonapi.ContainerKeyValue{ + Key: key, + ValueFrom: &commonapi.ContainerValueSource{ + Credential: &commonapi.ContainerValueSourceCredential{ + Id: cred.Id, + Key: key, + }, + }, + }) + } + return envs +} + +func init() { + models.RegisterLLMContainerDriver(newOpenClaw()) +} + +type openclaw struct { + baseDriver +} + +func newOpenClaw() models.ILLMContainerDriver { + return &openclaw{baseDriver: newBaseDriver(api.LLM_CONTAINER_OPENCLAW)} +} + +func (c *openclaw) GetSpec(sku *models.SLLMSku) interface{} { + if sku == nil || sku.LLMSpec == nil { + return nil + } + return sku.LLMSpec.OpenClaw +} + +// mergeOpenClaw merges llm and sku OpenClaw specs; llm takes priority, use sku when llm field is unset (nil or empty). +func mergeOpenClaw(llm, sku *api.LLMSpecOpenClaw) *api.LLMSpecOpenClaw { + if llm == nil { + if sku == nil { + return nil + } + return copyOpenClaw(sku) + } + if sku == nil { + return copyOpenClaw(llm) + } + out := &api.LLMSpecOpenClaw{} + if len(llm.Providers) > 0 { + out.Providers = make([]*api.LLMSpecOpenClawProvider, len(llm.Providers)) + copy(out.Providers, llm.Providers) + } else if len(sku.Providers) > 0 { + out.Providers = make([]*api.LLMSpecOpenClawProvider, len(sku.Providers)) + copy(out.Providers, sku.Providers) + } + if len(llm.Channels) > 0 { + out.Channels = make([]*api.LLMSpecOpenClawChannel, len(llm.Channels)) + copy(out.Channels, llm.Channels) + } else if len(sku.Channels) > 0 { + out.Channels = make([]*api.LLMSpecOpenClawChannel, len(sku.Channels)) + copy(out.Channels, sku.Channels) + } + if llm.WorkspaceTemplates != nil && (llm.WorkspaceTemplates.AgentsMD != "" || llm.WorkspaceTemplates.SoulMD != "" || llm.WorkspaceTemplates.UserMD != "") { + out.WorkspaceTemplates = &api.LLMSpecOpenClawWorkspaceTemplates{ + AgentsMD: llm.WorkspaceTemplates.AgentsMD, + SoulMD: llm.WorkspaceTemplates.SoulMD, + UserMD: llm.WorkspaceTemplates.UserMD, + } + } else if sku.WorkspaceTemplates != nil { + out.WorkspaceTemplates = &api.LLMSpecOpenClawWorkspaceTemplates{ + AgentsMD: sku.WorkspaceTemplates.AgentsMD, + SoulMD: sku.WorkspaceTemplates.SoulMD, + UserMD: sku.WorkspaceTemplates.UserMD, + } + } + return out +} + +func copyOpenClaw(s *api.LLMSpecOpenClaw) *api.LLMSpecOpenClaw { + if s == nil { + return nil + } + out := &api.LLMSpecOpenClaw{} + if len(s.Providers) > 0 { + out.Providers = make([]*api.LLMSpecOpenClawProvider, len(s.Providers)) + copy(out.Providers, s.Providers) + } + if len(s.Channels) > 0 { + out.Channels = make([]*api.LLMSpecOpenClawChannel, len(s.Channels)) + copy(out.Channels, s.Channels) + } + if s.WorkspaceTemplates != nil { + out.WorkspaceTemplates = &api.LLMSpecOpenClawWorkspaceTemplates{ + AgentsMD: s.WorkspaceTemplates.AgentsMD, + SoulMD: s.WorkspaceTemplates.SoulMD, + UserMD: s.WorkspaceTemplates.UserMD, + } + } + return out +} + +func (c *openclaw) GetEffectiveSpec(llm *models.SLLM, sku *models.SLLMSku) interface{} { + if sku == nil || sku.LLMSpec == nil { + return nil + } + var llmOC *api.LLMSpecOpenClaw + if llm != nil && llm.LLMSpec != nil { + llmOC = llm.LLMSpec.OpenClaw + } + return mergeOpenClaw(llmOC, sku.LLMSpec.OpenClaw) +} + +func (c *openclaw) StartLLM(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) error { + // lc, err := llm.GetLLMContainer() + // if err != nil { + // return errors.Wrap(err, "get llm container") + // } + // // 启动 openclaw gateway + // cmd := fmt.Sprintf("/app/scripts/entrypoint-gui.sh") + // _, err = exec(ctx, lc.CmpId, cmd, 30) + // if err != nil { + // return errors.Wrap(err, "exec start openclaw gateway") + // } + return nil +} + +func (c *openclaw) GetContainerSpec(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput { + // Multi-container: use GetContainerSpecs + return nil +} + +// func (c *openclaw) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { +// diskIndex := 0 + +// // 1. Browser sidecar: CDP on 9222, persistent /config, shm 2g +// browserVols := []*commonapi.ContainerVolumeMount{ +// { +// Disk: &commonapi.ContainerVolumeMountDisk{ +// Index: &diskIndex, +// SubDirectory: browserStorageDir, +// }, +// Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, +// MountPath: browserConfigDir, +// }, +// } +// browserSpec := computeapi.ContainerSpec{ +// ContainerSpec: commonapi.ContainerSpec{ +// Image: openclawBrowserImage, +// EnableLxcfs: true, +// AlwaysRestart: true, +// ShmSizeMB: 2048, // 2g for Chrome +// Envs: []*commonapi.ContainerKeyValue{ +// {Key: "PUID", Value: "1000"}, +// {Key: "PGID", Value: "1000"}, +// {Key: "TZ", Value: "Etc/UTC"}, +// {Key: "CHROME_CLI", Value: "--remote-debugging-port=" + openclawBrowserCDPPort}, +// }, +// }, +// VolumeMounts: browserVols, +// } + +// // 2. OpenClaw main: nginx :8080 -> gateway :18789, /data, depends on browser +// openclawVols := []*commonapi.ContainerVolumeMount{ +// { +// Disk: &commonapi.ContainerVolumeMountDisk{ +// Index: &diskIndex, +// SubDirectory: "data", +// }, +// Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, +// MountPath: openclawDataDir, +// }, +// } +// openclawSpec := computeapi.ContainerSpec{ +// ContainerSpec: commonapi.ContainerSpec{ +// Image: image.ToContainerImage(), +// ImageCredentialId: image.CredentialId, +// EnableLxcfs: true, +// AlwaysRestart: true, +// DependsOn: []string{fmt.Sprintf("%s-%s", llm.GetName(), browserContainerName)}, +// Envs: []*commonapi.ContainerKeyValue{ +// // Provider +// {Key: "MOONSHOT_API_KEY", Value: "sk-9taa32DcGGQliadQTEcZfpMUL9LCAnZVfyE6hKWPUMWEofJ8"}, +// {Key: "OPENCLAW_PRIMARY_MODEL", Value: "moonshot/kimi-k2.5"}, +// // Auth +// {Key: "AUTH_USERNAME", Value: "admin"}, +// {Key: "AUTH_PASSWORD", Value: "admin@123"}, +// {Key: "OPENCLAW_GATEWAY_TOKEN", Value: "90d42cfc7a925201a27b61ce9b6403693629d2a18094a596"}, +// // Browser sidecar +// {Key: "BROWSER_CDP_URL", Value: "http://localhost" + ":" + openclawBrowserCDPPort}, +// {Key: "BROWSER_DEFAULT_PROFILE", Value: "openclaw"}, +// {Key: "BROWSER_EVALUATE_ENABLED", Value: "true"}, +// }, +// }, +// VolumeMounts: openclawVols, +// } + +// return []*computeapi.PodContainerCreateInput{ +// {Name: fmt.Sprintf("%s-%s", llm.GetName(), browserContainerName), ContainerSpec: browserSpec}, +// {Name: fmt.Sprintf("%s-%s", llm.GetName(), openclawContainerName), ContainerSpec: openclawSpec}, +// } +// } + +func (c *openclaw) GetContainerSpecs(ctx context.Context, llm *models.SLLM, image *models.SLLMImage, sku *models.SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) []*computeapi.PodContainerCreateInput { + diskIndex := 0 + + openclawVols := []*commonapi.ContainerVolumeMount{ + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "config", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: browserConfigDir, + }, + { + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "data", + }, + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + MountPath: openclawDataDir, + }, + } + openclawSpec := computeapi.ContainerSpec{ + ContainerSpec: commonapi.ContainerSpec{ + Image: image.ToContainerImage(), + ImageCredentialId: image.CredentialId, + EnableLxcfs: true, + AlwaysRestart: true, + ShmSizeMB: 2048, + DisableNoNewPrivs: true, + Envs: []*commonapi.ContainerKeyValue{ + // Desktop env + // {Key: "TZ", Value: "Etc/UTC"}, + {Key: "TZ", Value: "Asia/Shanghai"}, + {Key: "PUID", Value: "1000"}, + {Key: "PGID", Value: "1000"}, + {Key: "LC_ALL", Value: "zh_CN.UTF-8"}, + // Provider + // {Key: "MOONSHOT_API_KEY", Value: "abc"}, + // {Key: "OPENCLAW_PRIMARY_MODEL", Value: "moonshot/kimi-k2.5"}, + // Auth + {Key: "AUTH_USERNAME", Value: "admin"}, + {Key: "CUSTOM_USER", Value: "admin"}, + {Key: "AUTH_PASSWORD", Value: "clawadmin@123"}, + {Key: "PASSWORD", Value: "clawadmin@123"}, + // // Browser sidecar + // {Key: "BROWSER_CDP_URL", Value: "http://localhost" + ":" + openclawBrowserCDPPort}, + // {Key: "BROWSER_DEFAULT_PROFILE", Value: "openclaw"}, + // {Key: "BROWSER_EVALUATE_ENABLED", Value: "true"}, + // OpenClaw env + {Key: "OPENCLAW_GATEWAY_TOKEN", Value: "abcd"}, + {Key: "OPENCLAW_GATEWAY_PORT", Value: "18789"}, + {Key: "OPENCLAW_GATEWAY_BIND", Value: "loopback"}, + {Key: "OPENCLAW_STATE_DIR", Value: "/config/.openclaw"}, + {Key: "OPENCLAW_WORKSPACE_DIR", Value: "/config/.openclaw/workspace"}, + // Brew env + {Key: "HOMEBREW_PREFIX", Value: "/home/linuxbrew/.linuxbrew"}, + {Key: "HOMEBREW_CELLAR", Value: "/home/linuxbrew/.linuxbrew/Cellar"}, + {Key: "HOMEBREW_REPOSITORY", Value: "/home/linuxbrew/.linuxbrew/Homebrew"}, + }, + }, + VolumeMounts: openclawVols, + RootFs: &commonapi.ContainerRootfs{ + Type: commonapi.CONTAINER_VOLUME_MOUNT_TYPE_DISK, + Disk: &commonapi.ContainerVolumeMountDisk{ + Index: &diskIndex, + SubDirectory: "rootfs", + }, + Persistent: true, + }, + } + // inject credential envs + spec := c.GetEffectiveSpec(llm, sku) + if spec == nil { + return []*computeapi.PodContainerCreateInput{ + { + Name: fmt.Sprintf("%s-%d", llm.GetName(), 0), + ContainerSpec: openclawSpec, + }, + } + } + skuSpec := spec.(*api.LLMSpecOpenClaw) + log.Infof("========sku spec: %s", jsonutils.Marshal(skuSpec).PrettyString()) + for _, provider := range skuSpec.Providers { + openclawSpec.Envs = appendCredentialEnvs(openclawSpec.Envs, provider.Credential) + } + for _, channel := range skuSpec.Channels { + openclawSpec.Envs = appendCredentialEnvs(openclawSpec.Envs, channel.Credential) + } + + return []*computeapi.PodContainerCreateInput{ + { + Name: fmt.Sprintf("%s-%d", llm.GetName(), 0), + ContainerSpec: openclawSpec, + }, + } +} + +func (c *openclaw) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM) (string, error) { + server, err := llm.GetServer(ctx) + if err != nil { + return "", errors.Wrap(err, "get server") + } + // 从 IPs 字符串中选择第一个 IP + ips := strings.Split(strings.TrimSpace(server.IPs), ",") + if len(ips) == 0 || len(strings.TrimSpace(ips[0])) == 0 { + return "", errors.Error("server IPs is empty") + } + firstIP := strings.TrimSpace(ips[0]) + return fmt.Sprintf("https://%s:%d", firstIP, 3001), nil +} + +func (c *openclaw) GetProbedInstantModelsExt(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, mdlIds ...string) (map[string]api.LLMInternalInstantMdlInfo, error) { + return nil, nil +} + +func (c *openclaw) DetectModelPaths(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, pkgInfo api.LLMInternalInstantMdlInfo) ([]string, error) { + return nil, nil +} + +func (c *openclaw) GetImageInternalPathMounts(sApp *models.SInstantModel) map[string]string { + return nil +} + +func (c *openclaw) GetSaveDirectories(sApp *models.SInstantModel) (string, []string, error) { + return "", nil, nil +} + +func (c *openclaw) ValidateMounts(mounts []string, mdlName string, mdlTag string) ([]string, error) { + return nil, nil +} + +func (c *openclaw) CheckDuplicateMounts(errStr string, dupIndex int) string { + return "Duplicate mounts detected" +} + +func (c *openclaw) GetInstantModelIdByPostOverlay(postOverlay *commonapi.ContainerVolumeMountDiskPostOverlay, mdlNameToId map[string]string) string { + return "" +} + +func (c *openclaw) GetDirPostOverlay(dir api.LLMMountDirInfo) *commonapi.ContainerVolumeMountDiskPostOverlay { + return nil +} + +func (c *openclaw) PreInstallModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, instMdl *models.SLLMInstantModel) error { + return nil +} + +func (c *openclaw) InstallModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, dirs []string, mdlIds []string) error { + return nil +} + +func (c *openclaw) UninstallModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, instMdl *models.SLLMInstantModel) error { + return nil +} + +func (c *openclaw) DownloadModel(ctx context.Context, userCred mcclient.TokenCredential, llm *models.SLLM, tmpDir string, modelName string, modelTag string) (string, []string, error) { + return "", nil, nil +} diff --git a/pkg/llm/models/dify_containers.go b/pkg/llm/models/dify_containers.go index af1c4a7213..67b8448ac8 100644 --- a/pkg/llm/models/dify_containers.go +++ b/pkg/llm/models/dify_containers.go @@ -100,34 +100,33 @@ func getDifySpecFromSku(sku *SLLMSku) *api.LLMSpecDify { return d } -func getDifyContainerByNameKeyAndSku(name, key string, sku *SLLMSku, customEnvs *DifyContainerEnv) (*computeapi.PodContainerCreateInput, error) { - spec := getDifySpecFromSku(sku) - if spec == nil { - return nil, errors.New("sku is not a Dify SKU or LLMSpec is missing") +func getDifyContainerByNameKeyAndSku(name, key string, difySpec *api.LLMSpecDify, customEnvs *DifyContainerEnv) (*computeapi.PodContainerCreateInput, error) { + if difySpec == nil { + return nil, errors.New("dify spec is nil") } switch key { case api.DIFY_REDIS_KEY: - return getRedisContainer(name, key, _getRegistryImage(spec.RedisImageId), customEnvs), nil + return getRedisContainer(name, key, _getRegistryImage(difySpec.RedisImageId), customEnvs), nil case api.DIFY_POSTGRES_KEY: - return getPostgresContainer(name, key, _getRegistryImage(spec.PostgresImageId), customEnvs), nil + return getPostgresContainer(name, key, _getRegistryImage(difySpec.PostgresImageId), customEnvs), nil case api.DIFY_API_KEY: - return getApiContainer(name, key, _getRegistryImage(spec.DifyApiImageId), customEnvs), nil + return getApiContainer(name, key, _getRegistryImage(difySpec.DifyApiImageId), customEnvs), nil case api.DIFY_WORKER_KEY: - return getWorkerContainer(name, key, _getRegistryImage(spec.DifyApiImageId), customEnvs), nil + return getWorkerContainer(name, key, _getRegistryImage(difySpec.DifyApiImageId), customEnvs), nil case api.DIFY_WORKER_BEAT_KEY: - return getWorkerBeatContainer(name, key, _getRegistryImage(spec.DifyApiImageId), customEnvs), nil + return getWorkerBeatContainer(name, key, _getRegistryImage(difySpec.DifyApiImageId), customEnvs), nil case api.DIFY_PLUGIN_KEY: - return getPluginContainer(name, key, _getRegistryImage(spec.DifyPluginImageId), customEnvs), nil + return getPluginContainer(name, key, _getRegistryImage(difySpec.DifyPluginImageId), customEnvs), nil case api.DIFY_WEB_KEY: - return getWebContainer(name, key, _getRegistryImage(spec.DifyWebImageId), customEnvs), nil + return getWebContainer(name, key, _getRegistryImage(difySpec.DifyWebImageId), customEnvs), nil case api.DIFY_SSRF_KEY: - return getSsrfContainer(name, key, _getRegistryImage(spec.DifySSRFImageId), customEnvs), nil + return getSsrfContainer(name, key, _getRegistryImage(difySpec.DifySSRFImageId), customEnvs), nil case api.DIFY_NGINX_KEY: - return getNginxContainer(name, key, _getRegistryImage(spec.NginxImageId), customEnvs), nil + return getNginxContainer(name, key, _getRegistryImage(difySpec.NginxImageId), customEnvs), nil case api.DIFY_WEAVIATE_KEY: - return getWeaviateContainer(name, key, _getRegistryImage(spec.DifyWeaviateImageId), customEnvs), nil + return getWeaviateContainer(name, key, _getRegistryImage(difySpec.DifyWeaviateImageId), customEnvs), nil case api.DIFY_SANDBOX_KEY: - return getSandboxContainer(name, key, _getRegistryImage(spec.DifySandboxImageId), customEnvs), nil + return getSandboxContainer(name, key, _getRegistryImage(difySpec.DifySandboxImageId), customEnvs), nil default: return nil, errors.New("unsupported container key") } @@ -170,10 +169,13 @@ func mergeDifyContainerEnvs(base, overrides *DifyContainerEnv) *DifyContainerEnv return &out } -func GetDifyContainersByNameAndSku(name string, sku *SLLMSku, customEnvs *DifyContainerEnv) []*computeapi.PodContainerCreateInput { +func GetDifyContainersByNameAndSku(name string, sku *SLLMSku, customEnvs *DifyContainerEnv, difySpec *api.LLMSpecDify) []*computeapi.PodContainerCreateInput { + if difySpec == nil { + difySpec = getDifySpecFromSku(sku) + } var skuEnvs *DifyContainerEnv - if d := getDifySpecFromSku(sku); d != nil && len(d.CustomizedEnvs) > 0 { - m := DifyCustomizedEnvsToMap(d.CustomizedEnvs) + if difySpec != nil && len(difySpec.CustomizedEnvs) > 0 { + m := DifyCustomizedEnvsToMap(difySpec.CustomizedEnvs) if m != nil { skuEnvs = &m } @@ -182,7 +184,7 @@ func GetDifyContainersByNameAndSku(name string, sku *SLLMSku, customEnvs *DifyCo var out []*computeapi.PodContainerCreateInput for _, key := range DifyContainerKeys { - c, err := getDifyContainerByNameKeyAndSku(name, key, sku, mergedEnvs) + c, err := getDifyContainerByNameKeyAndSku(name, key, difySpec, mergedEnvs) if err != nil { continue } diff --git a/pkg/llm/models/llm.go b/pkg/llm/models/llm.go index 217ad5d4cf..53365c89f3 100644 --- a/pkg/llm/models/llm.go +++ b/pkg/llm/models/llm.go @@ -61,6 +61,9 @@ type SLLM struct { // 秒装应用配额(可安装的总容量限制) InstantModelQuotaGb int `list:"user" update:"user" create:"optional" default:"0" nullable:"false"` + + // LLMSpec overrides/extends sku LLMSpec when building container; merged with sku.LLMSpec (llm priority). + LLMSpec *api.LLMSpec `json:"llm_spec,omitempty" length:"long" list:"user" create:"optional" update:"user"` } // CustomizeCreate saves Dify customized envs from create input when present. @@ -195,6 +198,7 @@ func (man *SLLMManager) FetchCustomizeColumns( for i := range llms { if sku, ok := skus[llms[i].LLMSkuId]; ok { res[i].LLMSku = sku.Name + res[i].LLMType = sku.LLMType res[i].VcpuCount = sku.Cpu res[i].VmemSizeMb = sku.Memory res[i].Devices = sku.Devices @@ -557,6 +561,9 @@ func (llm *SLLM) StartSyncStatusTask(ctx context.Context, userCred mcclient.Toke } func (llm *SLLM) GetLLMUrl(ctx context.Context, userCred mcclient.TokenCredential) (string, error) { + if llm.CmpId == "" { + return "", nil + } return llm.GetLLMContainerDriver().GetLLMUrl(ctx, userCred, llm) } diff --git a/pkg/llm/models/llm_container_driver.go b/pkg/llm/models/llm_container_driver.go index 7302f7a5f1..a6b02b2962 100644 --- a/pkg/llm/models/llm_container_driver.go +++ b/pkg/llm/models/llm_container_driver.go @@ -85,6 +85,7 @@ type ILLMContainerDriverMultiContainer interface { type ILLMContainerDriver interface { GetType() llm.LLMContainerType + // GetContainerSpecs returns one or more container specs. If nil or empty, caller falls back to GetContainerSpec for a single container. GetContainerSpec(ctx context.Context, llm *SLLM, image *SLLMImage, sku *SLLMSku, props []string, devices []computeapi.SIsolatedDevice, diskId string) *computeapi.PodContainerCreateInput // StartLLM is called after the pod is running. For drivers that need to start the model process inside the container (e.g. vLLM), it runs the start command via exec and waits for health; on failure returns an error. For drivers that need no extra step (e.g. Ollama), it returns nil. @@ -92,12 +93,14 @@ type ILLMContainerDriver interface { // GetSpec returns the type-specific spec from the SKU (e.g. *LLMSpecOllama, *LLMSpecDify). Returns nil if not applicable or missing. GetSpec(sku *SLLMSku) interface{} + // GetEffectiveSpec returns the merged type-specific spec for container build: llm.LLMSpec and sku.LLMSpec merged with llm priority; each driver implements its own merge. Returns same type as GetSpec. + GetEffectiveSpec(llm *SLLM, sku *SLLMSku) interface{} // GetPrimaryImageId returns the primary image id for this SKU type (e.g. LLMImageId for ollama/vllm, DifyApiImageId for dify). GetPrimaryImageId(sku *SLLMSku) string - // ValidateCreateSpec validates create input and returns the LLMSpec to store. Called by SKU manager after base validation. - ValidateCreateSpec(ctx context.Context, userCred mcclient.TokenCredential, input *llm.LLMSkuCreateInput) (*llm.LLMSpec, error) - // ValidateUpdateSpec validates update input, merges with current spec, and returns the LLMSpec to store. Called by SKU when LLMSpec is not nil. - ValidateUpdateSpec(ctx context.Context, userCred mcclient.TokenCredential, sku *SLLMSku, input *llm.LLMSkuUpdateInput) (*llm.LLMSpec, error) + // ValidateCreateData validates create input and returns the LLMSpec to store. Called by SKU manager after base validation. + ValidateCreateData(ctx context.Context, userCred mcclient.TokenCredential, input *llm.LLMSkuCreateInput) (*llm.LLMSkuCreateInput, error) + // ValidateUpdateData validates update input, merges with current spec, and returns the LLMSpec to store. Called by SKU when LLMSpec is not nil. + ValidateUpdateData(ctx context.Context, userCred mcclient.TokenCredential, sku *SLLMSku, input *llm.LLMSkuUpdateInput) (*llm.LLMSkuUpdateInput, error) ILLMContainerMCPAgent } diff --git a/pkg/llm/models/llm_sku.go b/pkg/llm/models/llm_sku.go index 9ee24d5553..28d1caef0e 100644 --- a/pkg/llm/models/llm_sku.go +++ b/pkg/llm/models/llm_sku.go @@ -49,10 +49,12 @@ type SLLMSkuManager struct { type SLLMSku struct { SLLMSkuBase - // SMountedModelsResource + SMountedModelsResource - LLMType string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"` - LLMSpec *api.LLMSpec `json:"llm_spec" length:"long" list:"user" create:"required" update:"user"` + // primary image id of primary container + LLMImageId string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required" update:"user"` + LLMType string `width:"128" charset:"ascii" nullable:"false" list:"user" create:"required"` + LLMSpec *api.LLMSpec `json:"llm_spec" length:"long" list:"user" create:"required" update:"user"` } func (man *SLLMSkuManager) ListItemFilter( @@ -199,11 +201,10 @@ func (man *SLLMSkuManager) ValidateCreateData(ctx context.Context, userCred mccl if err != nil { return input, errors.Wrap(err, "get container driver") } - spec, err := drv.ValidateCreateSpec(ctx, userCred, input) + input, err = drv.ValidateCreateData(ctx, userCred, input) if err != nil { - return input, errors.Wrap(err, "validate create spec") + return input, errors.Wrap(err, "validate create input") } - input.LLMSpec = spec input.Status = api.STATUS_READY return input, nil } @@ -237,14 +238,11 @@ func (sku *SLLMSku) ValidateUpdateData(ctx context.Context, userCred mcclient.To return input, nil } drv := sku.GetLLMContainerDriver() - spec, err := drv.ValidateUpdateSpec(ctx, userCred, sku, &input) + updateInput, err := drv.ValidateUpdateData(ctx, userCred, sku, &input) if err != nil { return input, errors.Wrap(err, "validate update spec") } - if spec != nil { - input.LLMSpec = spec - } - return input, nil + return *updateInput, nil } func (sku *SLLMSku) ValidateDeleteCondition(ctx context.Context, info jsonutils.JSONObject) error { diff --git a/pkg/mcclient/options/llm/image.go b/pkg/mcclient/options/llm/image.go index 214effc043..31d0003a9e 100644 --- a/pkg/mcclient/options/llm/image.go +++ b/pkg/mcclient/options/llm/image.go @@ -18,7 +18,7 @@ func (o *LLMImageShowOptions) Params() (jsonutils.JSONObject, error) { type LLMImageListOptions struct { options.BaseListOptions - LLMType string `json:"llm_type" choices:"ollama|dify" help:"filter by llm type"` + LLMType string `json:"llm_type" choices:"ollama|dify|comfyui" help:"filter by llm type"` } func (o *LLMImageListOptions) Params() (jsonutils.JSONObject, error) { @@ -30,7 +30,7 @@ type LLMImageCreateOptions struct { IMAGE_NAME string `json:"image_name"` IMAGE_LABEL string `json:"image_label"` CredentialId string `json:"credential_id"` - LLM_TYPE string `json:"llm_type" choices:"ollama|dify" help:"llm type: ollama or dify"` + LLM_TYPE string `json:"llm_type" choices:"ollama|dify|comfyui" help:"llm type: ollama, comfyui or dify"` } func (o *LLMImageCreateOptions) Params() (jsonutils.JSONObject, error) { @@ -44,7 +44,7 @@ type LLMImageUpdateOptions struct { ImageName string `json:"image_name"` ImageLabel string `json:"image_label"` CredentialId string `json:"credential_id"` - LlmType string `json:"llm_type" choices:"ollama|dify" help:"llm type: ollama or dify"` + LlmType string `json:"llm_type" choices:"ollama|dify|vllm|comfyui" help:"llm type: ollama, comfyui or dify"` } func (o *LLMImageUpdateOptions) GetId() string { diff --git a/pkg/mcclient/options/llm/llm_sku.go b/pkg/mcclient/options/llm/llm_sku.go index c78c46b576..0a2637d906 100644 --- a/pkg/mcclient/options/llm/llm_sku.go +++ b/pkg/mcclient/options/llm/llm_sku.go @@ -10,7 +10,7 @@ import ( type LLMSkuListOptions struct { options.BaseListOptions - LLMType string `json:"llm_type" choices:"ollama|vllm|dify"` + LLMType string `json:"llm_type" choices:"ollama|comfyui|openclaw"` } func (o *LLMSkuListOptions) Params() (jsonutils.JSONObject, error) { @@ -31,7 +31,7 @@ type LLMSkuCreateOptions struct { MountedModels []string `help:"mounted models, e.g. qwen2:0.5b-dup" json:"mounted_models"` LLM_IMAGE_ID string `json:"llm_image_id"` - LLM_TYPE string `json:"llm_type" choices:"ollama|vllm"` + LLM_TYPE string `json:"llm_type" choices:"ollama|vllm|comfyui"` PreferredModel string `help:"preferred model (vllm only), sets llm_spec.vllm.preferred_model" json:"-"` } diff --git a/scripts/sync_dify_images.sh b/scripts/sync_dify_images.sh deleted file mode 100644 index 3db518fb28..0000000000 --- a/scripts/sync_dify_images.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -# 用法: -# ./sync-images.sh -# 示例: -# ./sync-images.sh crpi-nf3abu98o8qf9y2x.cn-beijing.personal.cr.aliyuncs.com/eikoh - -set -euo pipefail - -if [ $# -ne 1 ]; then - echo "用法: $0 " - echo "例如: $0 crpi-nf3abu98o8qf9y2x.cn-beijing.personal.cr.aliyuncs.com/eikoh" - exit 1 -fi - -TARGET_REGISTRY="$1" -SOURCE_REGISTRY="docker.io" - -# ---------------------------- -# 要同步的镜像列表 -# ---------------------------- -IMAGES=( - "nginx:latest" - "redis:6-alpine" - "postgres:15-alpine" - "langgenius/dify-api:1.7.2" - "langgenius/dify-sandbox:0.2.12" - "langgenius/dify-plugin-daemon:0.2.0-local" - "langgenius/dify-web:1.7.2" - "ubuntu/squid:latest" - "semitechnologies/weaviate:1.19.0" -) - -for image in "${IMAGES[@]}"; do - # 拆分 name 和 tag - if [[ "$image" == *":"* ]]; then - name="${image%%:*}" # 冒号前 - tag="${image##*:}" # 冒号后 - else - name="$image" - tag="latest" - fi - - short_name="${name##*/}" # 目标镜像只取最后一级名字 - - SRC="docker://${SOURCE_REGISTRY}/${name}:${tag}" - DST="docker://${TARGET_REGISTRY}/${short_name}:${tag}" - - echo - echo "Sync dify image" - echo " Source: ${SRC}" - echo " Target: ${DST}" - echo - - skopeo copy --override-os linux --override-arch amd64 "${SRC}" "${DST}" - - echo "Completed: ${short_name}:${tag}" -done - -echo "All images sync completed" diff --git a/scripts/sync_llm_images.sh b/scripts/sync_llm_images.sh new file mode 100644 index 0000000000..6c47dec1e4 --- /dev/null +++ b/scripts/sync_llm_images.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# 用法: +# ./sync-images.sh +# 示例: +# ./sync-images.sh crpi-nf3abu98o8qf9y2x.cn-beijing.personal.cr.aliyuncs.com/eikoh + +set -euo pipefail + +if [ $# -ne 1 ]; then + echo "用法: $0 " + echo "例如: $0 crpi-nf3abu98o8qf9y2x.cn-beijing.personal.cr.aliyuncs.com/eikoh" + exit 1 +fi + +TARGET_REGISTRY="$1" +SOURCE_REGISTRY="${SOURCE_REGISTRY:-docker.io}" + +# ---------------------------- +# 要同步的镜像列表 +# ---------------------------- +IMAGES=( + # "nginx:latest" + # "redis:6-alpine" + # "postgres:15-alpine" + # "langgenius/dify-api:1.7.2" + # "langgenius/dify-sandbox:0.2.12" + # "langgenius/dify-plugin-daemon:0.2.0-local" + # "langgenius/dify-web:1.7.2" + # "ubuntu/squid:latest" + # "semitechnologies/weaviate:1.19.0" + # "ollama/ollama:0.15.1" + # "vllm/vllm-openai:v0.15.1" + # "yanwk/comfyui-boot:cu128-slim" + # "node:22-bookworm" + # "coollabsio/openclaw:latest" + # "coollabsio/openclaw-browser:latest" + # ghcr.io/coollabsio/openclaw-base:latest + # lscr.io/linuxserver/webtop:ubuntu-xfce + registry.cn-beijing.aliyuncs.com/zexi/openclaw:ubu-20260309.4 +) + +for image in "${IMAGES[@]}"; do + # 拆分 name 和 tag + if [[ "$image" == *":"* ]]; then + name="${image%%:*}" # 冒号前 + tag="${image##*:}" # 冒号后 + else + name="$image" + tag="latest" + fi + + short_name="${name##*/}" # 目标镜像只取最后一级名字 + + # 如果 name 已经包含 registry(例如 ghcr.io/xxx 或 localhost:5000/xxx),就不要再前缀 docker.io + first_component="${name%%/*}" + if [[ "$name" == */* ]] && { [[ "$first_component" == *.* ]] || [[ "$first_component" == *:* ]] || [[ "$first_component" == "localhost" ]]; }; then + SRC="docker://${name}:${tag}" + else + SRC="docker://${SOURCE_REGISTRY}/${name}:${tag}" + fi + DST="docker://${TARGET_REGISTRY}/${short_name}:${tag}" + + echo + echo "Sync dify image" + echo " Source: ${SRC}" + echo " Target: ${DST}" + echo + + skopeo copy --override-os linux --multi-arch all "${SRC}" "${DST}" + + echo "Completed: ${short_name}:${tag}" +done + +echo "All images sync completed"