Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
948 lines
37 KiB
Go
948 lines
37 KiB
Go
package types
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/utils"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// retrieverEngineMapping maps RETRIEVE_DRIVER values to retriever engine configurations
|
|
var retrieverEngineMapping = map[string][]RetrieverEngineParams{
|
|
"postgres": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: PostgresRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: PostgresRetrieverEngineType},
|
|
},
|
|
"elasticsearch_v7": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType},
|
|
},
|
|
"elasticsearch_v8": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: ElasticsearchRetrieverEngineType},
|
|
},
|
|
"qdrant": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: QdrantRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: QdrantRetrieverEngineType},
|
|
},
|
|
"milvus": {
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: MilvusRetrieverEngineType},
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: MilvusRetrieverEngineType},
|
|
},
|
|
"weaviate": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: WeaviateRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: WeaviateRetrieverEngineType},
|
|
},
|
|
"doris": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: DorisRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: DorisRetrieverEngineType},
|
|
},
|
|
"sqlite": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: SQLiteRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: SQLiteRetrieverEngineType},
|
|
},
|
|
"tencent_vectordb": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: TencentVectorDBRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: TencentVectorDBRetrieverEngineType},
|
|
},
|
|
"opensearch": {
|
|
{RetrieverType: KeywordsRetrieverType, RetrieverEngineType: OpenSearchRetrieverEngineType},
|
|
{RetrieverType: VectorRetrieverType, RetrieverEngineType: OpenSearchRetrieverEngineType},
|
|
},
|
|
}
|
|
|
|
// GetRetrieverEngineMapping returns the retriever engine mapping
|
|
// This allows other packages to access the driver capabilities
|
|
func GetRetrieverEngineMapping() map[string][]RetrieverEngineParams {
|
|
return retrieverEngineMapping
|
|
}
|
|
|
|
// GetDefaultRetrieverEngines returns the default retriever engines based on RETRIEVE_DRIVER env
|
|
func GetDefaultRetrieverEngines() []RetrieverEngineParams {
|
|
result := []RetrieverEngineParams{}
|
|
seen := make(map[string]bool)
|
|
|
|
for _, driver := range strings.Split(os.Getenv("RETRIEVE_DRIVER"), ",") {
|
|
driver = strings.TrimSpace(driver)
|
|
if params, ok := retrieverEngineMapping[driver]; ok {
|
|
for _, p := range params {
|
|
key := string(p.RetrieverType) + ":" + string(p.RetrieverEngineType)
|
|
if !seen[key] {
|
|
seen[key] = true
|
|
result = append(result, p)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Tenant represents the tenant
|
|
type Tenant struct {
|
|
// ID
|
|
ID uint64 `yaml:"id" json:"id" gorm:"primaryKey"`
|
|
// Name
|
|
Name string `yaml:"name" json:"name"`
|
|
// Description
|
|
Description string `yaml:"description" json:"description"`
|
|
// Status
|
|
Status string `yaml:"status" json:"status" gorm:"default:'active'"`
|
|
// Retriever engines
|
|
RetrieverEngines RetrieverEngines `yaml:"retriever_engines" json:"retriever_engines" gorm:"type:json"`
|
|
// Business
|
|
Business string `yaml:"business" json:"business"`
|
|
// Storage quota (Bytes), default is 10GB, including vector, original file, text, index, etc.
|
|
StorageQuota int64 `yaml:"storage_quota" json:"storage_quota" gorm:"default:10737418240"`
|
|
// Storage used (Bytes)
|
|
StorageUsed int64 `yaml:"storage_used" json:"storage_used" gorm:"default:0"`
|
|
// Global Context configuration for this workspace (default for all sessions)
|
|
ContextConfig *ContextConfig `yaml:"context_config" json:"context_config" gorm:"type:jsonb"`
|
|
// Global WebSearch configuration for this workspace
|
|
WebSearchConfig *WebSearchConfig `yaml:"web_search_config" json:"web_search_config" gorm:"type:jsonb"`
|
|
// Parser engine config overrides (MinerU endpoint, API key, etc.). Used when parsing documents; overrides env.
|
|
ParserEngineConfig *ParserEngineConfig `yaml:"parser_engine_config" json:"parser_engine_config" gorm:"type:jsonb"`
|
|
// Credentials config: third-party provider credentials (e.g. WeKnoraCloud AppID/AppSecret)
|
|
Credentials *CredentialsConfig `yaml:"credentials" json:"credentials" gorm:"type:jsonb"`
|
|
// Storage engine config: parameters for Local, MinIO, COS. Used for document/file storage and docreader.
|
|
StorageEngineConfig *StorageEngineConfig `yaml:"storage_engine_config" json:"storage_engine_config" gorm:"type:jsonb"`
|
|
// DefaultStorageBackendID is the workspace default concrete storage instance.
|
|
DefaultStorageBackendID *string `yaml:"default_storage_backend_id" json:"default_storage_backend_id,omitempty" gorm:"column:default_storage_backend_id;type:varchar(36)"`
|
|
// Chat history config: knowledge base configuration for indexing and searching chat messages via vector search
|
|
ChatHistoryConfig *ChatHistoryConfig `yaml:"chat_history_config" json:"chat_history_config" gorm:"type:jsonb"`
|
|
// Retrieval config: global search/retrieval parameters shared by knowledge search and message search
|
|
RetrievalConfig *RetrievalConfig `yaml:"retrieval_config" json:"retrieval_config" gorm:"type:jsonb"`
|
|
// Memory config: workspace switch for cross-session long-term memory
|
|
MemoryConfig *MemoryConfig `yaml:"memory_config" json:"memory_config" gorm:"type:jsonb"`
|
|
// API principal config: controls how X-API-Key requests map to terminal principals.
|
|
APIPrincipalConfig *APIPrincipalConfig `yaml:"api_principal_config" json:"-" gorm:"type:jsonb"`
|
|
// Creation time
|
|
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
|
|
// Last updated time
|
|
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
|
|
// Deletion time
|
|
DeletedAt gorm.DeletedAt `yaml:"deleted_at" json:"deleted_at" gorm:"index"`
|
|
}
|
|
|
|
// RetrieverEngines represents the retriever engines for a tenant
|
|
type RetrieverEngines struct {
|
|
Engines []RetrieverEngineParams `yaml:"engines" json:"engines" gorm:"type:json"`
|
|
}
|
|
|
|
// GetEffectiveEngines returns the tenant's engines if configured, otherwise returns system defaults
|
|
func (t *Tenant) GetEffectiveEngines() []RetrieverEngineParams {
|
|
if len(t.RetrieverEngines.Engines) > 0 {
|
|
return t.RetrieverEngines.Engines
|
|
}
|
|
return GetDefaultRetrieverEngines()
|
|
}
|
|
|
|
// BeforeCreate is a hook function that is called before creating a tenant
|
|
func (t *Tenant) BeforeCreate(tx *gorm.DB) error {
|
|
if t.RetrieverEngines.Engines == nil {
|
|
t.RetrieverEngines.Engines = []RetrieverEngineParams{}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Value implements the driver.Valuer interface, used to convert RetrieverEngines to database value
|
|
func (c RetrieverEngines) Value() (driver.Value, error) {
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
// Scan implements the sql.Scanner interface, used to convert database value to RetrieverEngines.
|
|
// It supports both the legacy bare-array format (e.g. [{...}, {...}]) and the current
|
|
// object-wrapped format (e.g. {"engines": [{...}, {...}]}).
|
|
func (c *RetrieverEngines) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
b, ok := value.([]byte)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Try the current object format first: {"engines": [...]}
|
|
if err := json.Unmarshal(b, c); err == nil {
|
|
return nil
|
|
}
|
|
|
|
// Fallback: legacy bare-array format: [{...}, {...}]
|
|
var engines []RetrieverEngineParams
|
|
if err := json.Unmarshal(b, &engines); err != nil {
|
|
return fmt.Errorf("retriever_engines: cannot unmarshal as object or array: %w", err)
|
|
}
|
|
c.Engines = engines
|
|
return nil
|
|
}
|
|
|
|
// CredentialsConfig holds third-party provider credentials at the tenant level.
|
|
// Stored as a single JSONB column; each provider is a nested object so new
|
|
// providers can be added without schema changes.
|
|
type CredentialsConfig struct {
|
|
WeKnoraCloud *WeKnoraCloudCredentials `json:"weknoracloud,omitempty"`
|
|
}
|
|
|
|
// WeKnoraCloudCredentials stores WeKnoraCloud AppID and AppSecret.
|
|
// AppSecret is AES-256 encrypted before persisting to database.
|
|
type WeKnoraCloudCredentials struct {
|
|
AppID string `json:"app_id"`
|
|
AppSecret string `json:"app_secret"`
|
|
}
|
|
|
|
type APIPrincipalMode string
|
|
|
|
const (
|
|
APIPrincipalModeTenant APIPrincipalMode = "tenant"
|
|
APIPrincipalModeDirect APIPrincipalMode = "direct_header"
|
|
APIPrincipalModeSignedToken APIPrincipalMode = "signed_token"
|
|
)
|
|
|
|
// APIPrincipalConfig controls how tenant API-key requests map to terminal
|
|
// principals. Direct header mode is low-assurance and should only be used for
|
|
// trusted server-to-server calls; signed-token mode verifies the user claim.
|
|
type APIPrincipalConfig struct {
|
|
Mode APIPrincipalMode `json:"mode"`
|
|
DirectHeaderName string `json:"direct_header_name,omitempty"`
|
|
SignedTokenHeaderName string `json:"signed_token_header_name,omitempty"`
|
|
// RequireDirectHeader, when true in direct_header mode, rejects API-key
|
|
// requests that omit the configured user-id header instead of falling
|
|
// back to the tenant-level principal.
|
|
RequireDirectHeader bool `json:"require_direct_header,omitempty"`
|
|
HMACSecret string `json:"hmac_secret,omitempty"`
|
|
}
|
|
|
|
func (c *APIPrincipalConfig) Value() (driver.Value, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
cp := *c
|
|
if cp.HMACSecret != "" {
|
|
if key := utils.GetAESKey(); key != nil {
|
|
if encrypted, err := utils.EncryptAESGCM(cp.HMACSecret, key); err == nil {
|
|
cp.HMACSecret = encrypted
|
|
}
|
|
}
|
|
}
|
|
return json.Marshal(&cp)
|
|
}
|
|
|
|
func (c *APIPrincipalConfig) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
b, ok := value.([]byte)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(b, c); err != nil {
|
|
return err
|
|
}
|
|
if plain, ok := utils.DecryptStoredSecretLenient(c.HMACSecret); ok {
|
|
c.HMACSecret = plain
|
|
} else {
|
|
log.Printf("[crypto] tenant api_principal_config.hmac_secret: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured")
|
|
c.HMACSecret = ""
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetWeKnoraCloud returns the WeKnoraCloud credentials, or nil if not configured.
|
|
func (c *CredentialsConfig) GetWeKnoraCloud() *WeKnoraCloudCredentials {
|
|
if c == nil || c.WeKnoraCloud == nil {
|
|
return nil
|
|
}
|
|
if c.WeKnoraCloud.AppID == "" || c.WeKnoraCloud.AppSecret == "" {
|
|
return nil
|
|
}
|
|
return c.WeKnoraCloud
|
|
}
|
|
|
|
// Value implements the driver.Valuer interface for CredentialsConfig
|
|
func (c *CredentialsConfig) Value() (driver.Value, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
cp := *c
|
|
if cp.WeKnoraCloud != nil && cp.WeKnoraCloud.AppSecret != "" {
|
|
if key := utils.GetAESKey(); key != nil {
|
|
if encrypted, err := utils.EncryptAESGCM(cp.WeKnoraCloud.AppSecret, key); err == nil {
|
|
cp.WeKnoraCloud = &WeKnoraCloudCredentials{AppID: cp.WeKnoraCloud.AppID, AppSecret: encrypted}
|
|
}
|
|
}
|
|
}
|
|
return json.Marshal(cp)
|
|
}
|
|
|
|
// Scan implements the sql.Scanner interface for CredentialsConfig
|
|
func (c *CredentialsConfig) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
b, ok := value.([]byte)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(b, c); err != nil {
|
|
return err
|
|
}
|
|
if c.WeKnoraCloud != nil {
|
|
if plain, ok := utils.DecryptStoredSecretLenient(c.WeKnoraCloud.AppSecret); ok {
|
|
c.WeKnoraCloud.AppSecret = plain
|
|
} else {
|
|
log.Printf("[crypto] tenant credentials we_knora_cloud.app_secret: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured")
|
|
c.WeKnoraCloud.AppSecret = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ParserEngineConfig holds tenant-level overrides for document parser engines (e.g. MinerU endpoint, API key).
|
|
// These values take precedence over environment variables when parsing documents.
|
|
type ParserEngineConfig struct {
|
|
// ChatParserEngineRules selects parser engines for session-scoped chat
|
|
// documents. Knowledge bases keep their own rules in ChunkingConfig.
|
|
ChatParserEngineRules []ParserEngineRule `json:"chat_parser_engine_rules,omitempty"`
|
|
MinerUEndpoint string `json:"mineru_endpoint"` // MinerU 自建服务端点
|
|
MinerUAPIKey string `json:"mineru_api_key"` // MinerU 云 API Key
|
|
|
|
// MinerU 自建解析参数
|
|
MinerUModel string `json:"mineru_model,omitempty"` // backend: pipeline, vlm-*, hybrid-*
|
|
MinerUVLMServerURL string `json:"mineru_vlm_server_url,omitempty"` // vLLM 服务器地址 (vlm-http-client / hybrid-http-client)
|
|
MinerUEnableFormula *bool `json:"mineru_enable_formula,omitempty"`
|
|
MinerUEnableTable *bool `json:"mineru_enable_table,omitempty"`
|
|
MinerUParseMethod string `json:"mineru_parse_method,omitempty"`
|
|
// MinerUEnableOCR is retained for compatibility with configurations saved
|
|
// before parse_method supported auto/ocr/txt.
|
|
MinerUEnableOCR *bool `json:"mineru_enable_ocr,omitempty"`
|
|
MinerULanguage string `json:"mineru_language,omitempty"`
|
|
|
|
// MinerU 云 API 解析参数
|
|
MinerUCloudModel string `json:"mineru_cloud_model,omitempty"` // model_version: pipeline, vlm, MinerU-HTML
|
|
MinerUCloudEnableFormula *bool `json:"mineru_cloud_enable_formula,omitempty"`
|
|
MinerUCloudEnableTable *bool `json:"mineru_cloud_enable_table,omitempty"`
|
|
MinerUCloudEnableOCR *bool `json:"mineru_cloud_enable_ocr,omitempty"`
|
|
MinerUCloudLanguage string `json:"mineru_cloud_language,omitempty"`
|
|
|
|
// OpenDataLoader PDF (docreader engine); hybrid requires opendataloader-pdf-hybrid service.
|
|
ODLHybrid string `json:"odl_hybrid,omitempty"` // off (default), docling-fast, hancom-ai
|
|
ODLHybridURL string `json:"odl_hybrid_url,omitempty"` // e.g. http://odl-hybrid:5002
|
|
ODLHybridMode string `json:"odl_hybrid_mode,omitempty"` // auto, full
|
|
ODLHybridFallback *bool `json:"odl_hybrid_fallback,omitempty"`
|
|
ODLMarkdownWithHTML *bool `json:"odl_markdown_with_html,omitempty"`
|
|
|
|
// PaddleOCR-VL self-hosted pipeline service (full /layout-parsing API).
|
|
PaddleOCRVLEndpoint string `json:"paddleocr_vl_endpoint,omitempty"` // e.g. http://paddleocr-vl:8080
|
|
PaddleOCRVLUseSealRecognition *bool `json:"paddleocr_vl_use_seal_recognition,omitempty"`
|
|
PaddleOCRVLUseChartRecognition *bool `json:"paddleocr_vl_use_chart_recognition,omitempty"`
|
|
|
|
// PaddleOCR-VL AI Studio cloud API.
|
|
PaddleOCRVLCloudToken string `json:"paddleocr_vl_cloud_token,omitempty"`
|
|
PaddleOCRVLCloudModel string `json:"paddleocr_vl_cloud_model,omitempty"` // e.g. PaddleOCR-VL-1.6
|
|
PaddleOCRVLCloudUseSealRecognition *bool `json:"paddleocr_vl_cloud_use_seal_recognition,omitempty"`
|
|
PaddleOCRVLCloudUseChartRecognition *bool `json:"paddleocr_vl_cloud_use_chart_recognition,omitempty"`
|
|
}
|
|
|
|
const (
|
|
MinerUParseMethodAuto = "auto"
|
|
MinerUParseMethodOCR = "ocr"
|
|
MinerUParseMethodText = "txt"
|
|
)
|
|
|
|
// ResolveMinerUParseMethod normalizes the explicit MinerU parse method and
|
|
// maps the legacy OCR toggle to the closest safe behavior. The old enabled
|
|
// value maps to auto instead of ocr so digital PDFs keep their native text
|
|
// layer while scanned PDFs are still detected and OCRed by MinerU.
|
|
func ResolveMinerUParseMethod(method string, legacyOCREnabled *bool) string {
|
|
switch strings.ToLower(strings.TrimSpace(method)) {
|
|
case MinerUParseMethodAuto:
|
|
return MinerUParseMethodAuto
|
|
case MinerUParseMethodOCR:
|
|
return MinerUParseMethodOCR
|
|
case MinerUParseMethodText:
|
|
return MinerUParseMethodText
|
|
}
|
|
|
|
if legacyOCREnabled != nil && !*legacyOCREnabled {
|
|
return MinerUParseMethodText
|
|
}
|
|
return MinerUParseMethodAuto
|
|
}
|
|
|
|
func (c *ParserEngineConfig) ResolveChatParserEngine(fileType string) string {
|
|
if c != nil {
|
|
normalized := normalizeParserFileType(fileType)
|
|
for _, rule := range c.ChatParserEngineRules {
|
|
for _, candidate := range rule.FileTypes {
|
|
if normalizeParserFileType(candidate) == normalized {
|
|
return strings.TrimSpace(rule.Engine)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return DefaultParserEngine(fileType)
|
|
}
|
|
|
|
// ToOverridesMap returns a map suitable for ParserEngineOverrides in parse requests.
|
|
// Keys are snake_case (mineru_endpoint, mineru_api_key, etc.).
|
|
func (c *ParserEngineConfig) ToOverridesMap() map[string]string {
|
|
if c == nil {
|
|
return nil
|
|
}
|
|
m := make(map[string]string)
|
|
if c.MinerUEndpoint != "" {
|
|
m["mineru_endpoint"] = c.MinerUEndpoint
|
|
}
|
|
if c.MinerUAPIKey != "" {
|
|
m["mineru_api_key"] = c.MinerUAPIKey
|
|
}
|
|
if c.MinerUModel != "" {
|
|
m["mineru_model"] = c.MinerUModel
|
|
}
|
|
if c.MinerUVLMServerURL != "" {
|
|
m["mineru_vlm_server_url"] = c.MinerUVLMServerURL
|
|
}
|
|
if c.MinerUEnableFormula != nil {
|
|
m["mineru_enable_formula"] = fmt.Sprintf("%v", *c.MinerUEnableFormula)
|
|
}
|
|
if c.MinerUEnableTable != nil {
|
|
m["mineru_enable_table"] = fmt.Sprintf("%v", *c.MinerUEnableTable)
|
|
}
|
|
if c.MinerUParseMethod != "" || c.MinerUEnableOCR != nil {
|
|
m["mineru_parse_method"] = ResolveMinerUParseMethod(c.MinerUParseMethod, c.MinerUEnableOCR)
|
|
}
|
|
if c.MinerUEnableOCR != nil {
|
|
m["mineru_enable_ocr"] = fmt.Sprintf("%v", *c.MinerUEnableOCR)
|
|
}
|
|
if c.MinerULanguage != "" {
|
|
m["mineru_language"] = c.MinerULanguage
|
|
}
|
|
if c.MinerUCloudModel != "" {
|
|
m["mineru_cloud_model"] = c.MinerUCloudModel
|
|
}
|
|
if c.MinerUCloudEnableFormula != nil {
|
|
m["mineru_cloud_enable_formula"] = fmt.Sprintf("%v", *c.MinerUCloudEnableFormula)
|
|
}
|
|
if c.MinerUCloudEnableTable != nil {
|
|
m["mineru_cloud_enable_table"] = fmt.Sprintf("%v", *c.MinerUCloudEnableTable)
|
|
}
|
|
if c.MinerUCloudEnableOCR != nil {
|
|
m["mineru_cloud_enable_ocr"] = fmt.Sprintf("%v", *c.MinerUCloudEnableOCR)
|
|
}
|
|
if c.MinerUCloudLanguage != "" {
|
|
m["mineru_cloud_language"] = c.MinerUCloudLanguage
|
|
}
|
|
if c.ODLHybrid != "" {
|
|
m["odl_hybrid"] = c.ODLHybrid
|
|
}
|
|
if c.ODLHybridURL != "" {
|
|
m["odl_hybrid_url"] = c.ODLHybridURL
|
|
}
|
|
if c.ODLHybridMode != "" {
|
|
m["odl_hybrid_mode"] = c.ODLHybridMode
|
|
}
|
|
if c.ODLHybridFallback != nil {
|
|
m["odl_hybrid_fallback"] = fmt.Sprintf("%v", *c.ODLHybridFallback)
|
|
}
|
|
if c.ODLMarkdownWithHTML != nil {
|
|
m["odl_markdown_with_html"] = fmt.Sprintf("%v", *c.ODLMarkdownWithHTML)
|
|
}
|
|
if c.PaddleOCRVLEndpoint != "" {
|
|
m["paddleocr_vl_endpoint"] = c.PaddleOCRVLEndpoint
|
|
}
|
|
if c.PaddleOCRVLUseSealRecognition != nil {
|
|
m["paddleocr_vl_use_seal_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLUseSealRecognition)
|
|
}
|
|
if c.PaddleOCRVLUseChartRecognition != nil {
|
|
m["paddleocr_vl_use_chart_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLUseChartRecognition)
|
|
}
|
|
if c.PaddleOCRVLCloudToken != "" {
|
|
m["paddleocr_vl_cloud_token"] = c.PaddleOCRVLCloudToken
|
|
}
|
|
if c.PaddleOCRVLCloudModel != "" {
|
|
m["paddleocr_vl_cloud_model"] = c.PaddleOCRVLCloudModel
|
|
}
|
|
if c.PaddleOCRVLCloudUseSealRecognition != nil {
|
|
m["paddleocr_vl_cloud_use_seal_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLCloudUseSealRecognition)
|
|
}
|
|
if c.PaddleOCRVLCloudUseChartRecognition != nil {
|
|
m["paddleocr_vl_cloud_use_chart_recognition"] = fmt.Sprintf("%v", *c.PaddleOCRVLCloudUseChartRecognition)
|
|
}
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Value implements the driver.Valuer interface for ParserEngineConfig
|
|
func (c *ParserEngineConfig) Value() (driver.Value, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
// Scan implements the sql.Scanner interface for ParserEngineConfig
|
|
func (c *ParserEngineConfig) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
b, ok := value.([]byte)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(b, c)
|
|
}
|
|
|
|
// StorageEngineConfig holds tenant-level storage engine parameters for Local, MinIO, COS, TOS, S3, OSS, KS3, and OBS.
|
|
// Knowledge bases select which provider to use; parameters are read from here.
|
|
type StorageEngineConfig struct {
|
|
DefaultProvider string `json:"default_provider"` // "local", "minio", "cos", "tos", "s3", "oss", "ks3", "obs"
|
|
Local *LocalEngineConfig `json:"local,omitempty"`
|
|
MinIO *MinIOEngineConfig `json:"minio,omitempty"`
|
|
COS *COSEngineConfig `json:"cos,omitempty"`
|
|
TOS *TOSEngineConfig `json:"tos,omitempty"`
|
|
S3 *S3EngineConfig `json:"s3,omitempty"`
|
|
OSS *OSSEngineConfig `json:"oss,omitempty"`
|
|
KS3 *KS3EngineConfig `json:"ks3,omitempty"`
|
|
OBS *OBSEngineConfig `json:"obs,omitempty"`
|
|
}
|
|
|
|
// LocalEngineConfig is for local file system storage (single-machine deployment only).
|
|
type LocalEngineConfig struct {
|
|
PathPrefix string `json:"path_prefix"`
|
|
}
|
|
|
|
// MinIOEngineConfig is for MinIO/S3-compatible object storage.
|
|
// Mode "docker" uses env vars for endpoint/credentials; "remote" uses the fields below.
|
|
type MinIOEngineConfig struct {
|
|
Mode string `json:"mode"` // "docker" or "remote"
|
|
Endpoint string `json:"endpoint"`
|
|
AccessKeyID string `json:"access_key_id"`
|
|
SecretAccessKey string `json:"secret_access_key"`
|
|
BucketName string `json:"bucket_name"`
|
|
UseSSL bool `json:"use_ssl"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
}
|
|
|
|
// COSEngineConfig is for Tencent Cloud COS.
|
|
type COSEngineConfig struct {
|
|
SecretID string `json:"secret_id"`
|
|
SecretKey string `json:"secret_key"`
|
|
Region string `json:"region"`
|
|
BucketName string `json:"bucket_name"`
|
|
AppID string `json:"app_id"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
TempBucketName string `json:"temp_bucket_name"`
|
|
TempRegion string `json:"temp_region"`
|
|
}
|
|
|
|
// TOSEngineConfig is for Volcengine TOS (火山引擎对象存储).
|
|
type TOSEngineConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Region string `json:"region"`
|
|
AccessKey string `json:"access_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
BucketName string `json:"bucket_name"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
TempBucketName string `json:"temp_bucket_name"`
|
|
TempRegion string `json:"temp_region"`
|
|
}
|
|
|
|
// S3EngineConfig is for AWS S3 and S3-compatible object storage.
|
|
type S3EngineConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Region string `json:"region"`
|
|
AccessKey string `json:"access_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
BucketName string `json:"bucket_name"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
UseSSL bool `json:"use_ssl"`
|
|
ForcePathStyle bool `json:"force_path_style"`
|
|
}
|
|
|
|
// OSSEngineConfig is for Alibaba Cloud OSS (对象存储服务).
|
|
type OSSEngineConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Region string `json:"region"`
|
|
AccessKey string `json:"access_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
BucketName string `json:"bucket_name"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
UseTempBucket bool `json:"use_temp_bucket"`
|
|
TempBucketName string `json:"temp_bucket_name"`
|
|
TempRegion string `json:"temp_region"`
|
|
}
|
|
|
|
// KS3EngineConfig is for Kingsoft Cloud KS3 object storage.
|
|
type KS3EngineConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Region string `json:"region"`
|
|
AccessKey string `json:"access_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
BucketName string `json:"bucket_name"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
}
|
|
|
|
// OBSEngineConfig is for Huawei Cloud OBS (对象存储服务).
|
|
type OBSEngineConfig struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Region string `json:"region"`
|
|
AccessKey string `json:"access_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
BucketName string `json:"bucket_name"`
|
|
PathPrefix string `json:"path_prefix"`
|
|
UseSSL bool `json:"use_ssl"`
|
|
}
|
|
|
|
// Value implements the driver.Valuer interface for StorageEngineConfig
|
|
func (c *StorageEngineConfig) Value() (driver.Value, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
return json.Marshal(c)
|
|
}
|
|
|
|
// Scan implements the sql.Scanner interface for StorageEngineConfig
|
|
func (c *StorageEngineConfig) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
b, ok := value.([]byte)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(b, c)
|
|
}
|
|
|
|
// TenantSandboxConfig is one named sandbox backend a workspace maintains.
|
|
//
|
|
// It is self-contained: provider fields are not inherited from process
|
|
// environment. Leaving a required provider field empty is rejected on save.
|
|
type TenantSandboxConfig struct {
|
|
// SandboxType is cube, e2b, or docker; disabled is the hidden policy row.
|
|
SandboxType string `json:"sandbox_type,omitempty"`
|
|
|
|
// ── 通用配置(跨后端生效)──────────────────────────────────
|
|
|
|
// DefaultTimeoutSec is the per-execution timeout in seconds. 0 uses the
|
|
// program's built-in default.
|
|
DefaultTimeoutSec int `json:"default_timeout_sec,omitempty"`
|
|
|
|
// AllowPrivateEndpoints permits this workspace config to reach RFC1918 or
|
|
// loopback cluster endpoints. Link-local/cloud-metadata addresses remain
|
|
// blocked. It is explicit in the UI instead of hidden in process env.
|
|
AllowPrivateEndpoints bool `json:"allow_private_endpoints,omitempty"`
|
|
|
|
// EnvVars are additional environment variables injected into every
|
|
// sandbox created for this tenant. 🔒 Values are encrypted at rest.
|
|
// These become visible to all scripts running in the tenant's
|
|
// sandboxes — do not place secrets here that scripts must not access.
|
|
EnvVars map[string]string `json:"env_vars,omitempty"`
|
|
|
|
// VolumeMount configures an optional shared volume mounted into every
|
|
// sandbox created for this tenant. Currently used for tenant-installed
|
|
// skills, but the configuration itself is skill-agnostic and can serve
|
|
// any volume-mount use case (shared datasets, pre-installed toolchains,
|
|
// etc.).
|
|
VolumeMount *VolumeMountConfig `json:"volume_mount,omitempty"`
|
|
|
|
// SkillImage points at the snapshot that carries this config's installed
|
|
// skills. Empty means "use the base template". Written only by the skill
|
|
// install/remove path: MergeSandboxConfigForUpdate ignores client values
|
|
// so a settings-form save cannot wipe or plant the pointer.
|
|
SkillImage *SkillImageConfig `json:"skill_image,omitempty"`
|
|
|
|
// SkillRollout decides whether sessions that already hold a sandbox of
|
|
// this config rebuild after a skill install or removal. Empty and
|
|
// SkillRolloutNextTurn rebuild on the next chat turn. SkillRolloutNewSession
|
|
// leaves those sandboxes on the previous image; only sessions that start
|
|
// afterwards boot the new snapshot.
|
|
SkillRollout string `json:"skill_rollout,omitempty"`
|
|
|
|
// Network is the outbound/inbound network policy applied to every sandbox
|
|
// created from this config — chat sessions, skill installs and deep
|
|
// connectivity probes alike. nil and the zero value mean the same thing:
|
|
// outbound egress allowed, inbound public access closed.
|
|
Network *SandboxNetworkPolicy `json:"network,omitempty"`
|
|
|
|
// ── 后端专属配置(同一时刻只有一个生效,由 SandboxType 决定)───
|
|
|
|
Cube *CubeSandboxConfig `json:"cube,omitempty"`
|
|
E2B *E2BSandboxConfig `json:"e2b,omitempty"`
|
|
Docker *DockerSandboxConfig `json:"docker,omitempty"`
|
|
}
|
|
|
|
// CubeSandboxConfig addresses one CubeSandbox deployment. APIURL, ProxyURL,
|
|
// SandboxDomain and TemplateID are all required; APIKey is optional because the
|
|
// common single-node setup runs unauthenticated.
|
|
type CubeSandboxConfig struct {
|
|
APIURL string `json:"api_url,omitempty"`
|
|
ProxyURL string `json:"proxy_url,omitempty"`
|
|
SandboxDomain string `json:"sandbox_domain,omitempty"`
|
|
APIKey string `json:"api_key,omitempty"` // 加密
|
|
TemplateID string `json:"template_id,omitempty"`
|
|
|
|
// HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.
|
|
// 0 means use the built-in default (30s), never the deployment's value.
|
|
HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"`
|
|
|
|
CubeSandboxTTLSeconds int `json:"cube_sandbox_ttl_seconds,omitempty"`
|
|
|
|
// DNSServers are Cube template nameserver IPs. Empty uses Cubelet's default.
|
|
DNSServers []string `json:"dns_servers,omitempty"`
|
|
}
|
|
|
|
// E2BSandboxConfig addresses one E2B-protocol control plane: E2B Cloud, a
|
|
// self-hosted E2B Infrastructure, or any E2B-compatible implementation
|
|
// (CubeSandbox, Agent-Sandbox, …). APIKey and TemplateID are required; APIURL
|
|
// and SandboxDomain are optional because go-e2b resolves both on its own when
|
|
// they are empty.
|
|
type E2BSandboxConfig struct {
|
|
APIURL string `json:"api_url,omitempty"`
|
|
SandboxDomain string `json:"sandbox_domain,omitempty"`
|
|
APIKey string `json:"api_key,omitempty"` // 加密
|
|
TemplateID string `json:"template_id,omitempty"`
|
|
|
|
// ProxyURL is the data-plane gateway that fronts envd. E2B Cloud resolves
|
|
// "<port>-<sandboxID>.<sandbox_domain>" through public DNS and TLS, so it
|
|
// needs no value here. Self-hosted E2B-compatible control planes usually
|
|
// serve every sandbox from one gateway address and expect the sandbox
|
|
// authority in the Host header; setting this makes WeKnora dial the
|
|
// gateway directly instead of requiring wildcard DNS and a certificate
|
|
// for the sandbox domain. An "http://" gateway also downgrades the
|
|
// data-plane scheme, which the E2B SDK otherwise pins to https.
|
|
ProxyURL string `json:"proxy_url,omitempty"`
|
|
|
|
// HTTPTimeoutSec bounds each HTTP call to the sandbox control plane.
|
|
// 0 means use the built-in default (30s), never the deployment's value.
|
|
HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"`
|
|
|
|
E2BSandboxTTLSeconds int `json:"e2b_sandbox_ttl_seconds,omitempty"`
|
|
}
|
|
|
|
// DockerSandboxConfig addresses one Docker daemon. Image is required and plays
|
|
// the role a template ID plays for the MicroVM backends: every session
|
|
// container is created from it.
|
|
//
|
|
// The daemon endpoint is deliberately the only connection field, and TLS
|
|
// material is referenced by path rather than stored here. Client certificates
|
|
// are deployment infrastructure mounted onto the WeKnora host; keeping them
|
|
// out of the database keeps them out of backups, exports and API responses.
|
|
type DockerSandboxConfig struct {
|
|
Image string `json:"image,omitempty"`
|
|
|
|
// Host is the daemon endpoint in DOCKER_HOST form. Empty means the local
|
|
// unix socket.
|
|
Host string `json:"host,omitempty"`
|
|
|
|
// TLSCertPath is a directory on the WeKnora host containing ca.pem,
|
|
// cert.pem and key.pem. Required when Host is a TCP endpoint.
|
|
TLSCertPath string `json:"tls_cert_path,omitempty"`
|
|
|
|
// CPULimit is the number of CPU cores one sandbox may use. 0 uses the
|
|
// built-in default.
|
|
CPULimit float64 `json:"cpu_limit,omitempty"`
|
|
|
|
// MemoryLimitMB caps one sandbox's memory. 0 uses the built-in default.
|
|
MemoryLimitMB int `json:"memory_limit_mb,omitempty"`
|
|
|
|
// PidsLimit caps how many processes one sandbox may run. 0 uses the
|
|
// built-in default.
|
|
PidsLimit int `json:"pids_limit,omitempty"`
|
|
|
|
// NetworkMode is the Docker network sandboxes join: "bridge" (default) or
|
|
// "none" for no egress. Nothing else is accepted — host and container:
|
|
// modes share another namespace outright, and a named network is usually
|
|
// the deployment's own compose network, which would put the sandbox next
|
|
// to Postgres and Redis.
|
|
NetworkMode string `json:"network_mode,omitempty"`
|
|
|
|
// Runtime selects an alternative OCI runtime such as "runsc" (gVisor).
|
|
// Empty uses the daemon default.
|
|
Runtime string `json:"runtime,omitempty"`
|
|
|
|
// IdleTTLSeconds is how long a session container may go unused before it
|
|
// is reclaimed. The daemon has no idle timeout of its own, so this is what
|
|
// stops an abandoned session from pinning host memory indefinitely.
|
|
IdleTTLSeconds int `json:"idle_ttl_seconds,omitempty"`
|
|
|
|
// HTTPTimeoutSec bounds each Engine API call. 0 uses the built-in default.
|
|
HTTPTimeoutSec int `json:"http_timeout_sec,omitempty"`
|
|
}
|
|
|
|
// VolumeMountConfig configures a shared volume mount into every sandbox
|
|
// created for this tenant. Currently implemented for E2B volumes (used to
|
|
// share tenant-installed skills across sandbox sessions), but the schema is
|
|
// intentionally skill-agnostic so it can serve other volume-based use cases
|
|
// in the future.
|
|
type VolumeMountConfig struct {
|
|
// Enabled toggles the volume mount for this tenant.
|
|
Enabled bool `json:"enabled"`
|
|
|
|
// MountPath is the sandbox-internal path where the volume is mounted.
|
|
// Default: /weknora/tenant/skills (customizable per use case).
|
|
MountPath string `json:"mount_path,omitempty"`
|
|
|
|
// Provider identifies the volume backend. Currently "e2b" or "cube".
|
|
Provider string `json:"provider,omitempty"`
|
|
|
|
// VolumeID is the provider-specific volume identifier, populated after
|
|
// EnsureVolume / CreateVolume succeeds.
|
|
VolumeID string `json:"volume_id,omitempty"`
|
|
|
|
// VolumeName is the human-readable volume name, e.g.
|
|
// "weknora-tenant-<id>-skills".
|
|
VolumeName string `json:"volume_name,omitempty"`
|
|
|
|
// VolumeOwnerFingerprint = sha256(provider + APIKey + APIURL).
|
|
// Used to detect when the tenant switched to a different backend or
|
|
// API key, at which point the volume is no longer reachable and must
|
|
// be recreated.
|
|
VolumeOwnerFingerprint string `json:"volume_owner_fingerprint,omitempty"`
|
|
}
|
|
|
|
const (
|
|
// SkillRolloutNextTurn rebuilds a live session's sandbox on its next chat
|
|
// turn after the skill image changes. This is the default.
|
|
SkillRolloutNextTurn = "next_turn"
|
|
// SkillRolloutNewSession leaves live sandboxes on the previous image.
|
|
// Only a session that starts after the install or removal boots the new one.
|
|
SkillRolloutNewSession = "new_session"
|
|
)
|
|
|
|
// RebuildsExistingOnSkillChange reports whether an install or removal should
|
|
// mark already-bound sandboxes stale. Unknown values fail toward rebuilding
|
|
// so a corrupted row cannot pin every session on a retired image.
|
|
func (c *TenantSandboxConfig) RebuildsExistingOnSkillChange() bool {
|
|
if c == nil {
|
|
return true
|
|
}
|
|
return strings.TrimSpace(c.SkillRollout) != SkillRolloutNewSession
|
|
}
|
|
|
|
// SkillImageConfig is the pointer to the snapshot that carries the skills
|
|
// installed on this sandbox config. Snapshot IDs double as template IDs, so
|
|
// nothing else is needed to boot sessions from it.
|
|
//
|
|
// It holds no secrets, so it is not encrypted by TenantSandboxConfig.Value.
|
|
type SkillImageConfig struct {
|
|
// SnapshotID is the currently effective snapshot; empty = base template.
|
|
SnapshotID string `json:"snapshot_id,omitempty"`
|
|
// Generation increments on every successful install/remove, for naming
|
|
// and troubleshooting.
|
|
Generation int `json:"generation,omitempty"`
|
|
// BuiltAt records when this generation was produced.
|
|
BuiltAt time.Time `json:"built_at,omitempty"`
|
|
// BaseTemplateID is the template this chain was originally built from;
|
|
// the rebuild path starts over from it.
|
|
BaseTemplateID string `json:"base_template_id,omitempty"`
|
|
// OwnerFingerprint identifies the provider account that owns the snapshot.
|
|
// Snapshots are invisible across accounts, so a mismatch means "fall back
|
|
// to the base template" rather than "fail".
|
|
OwnerFingerprint string `json:"owner_fingerprint,omitempty"`
|
|
}
|
|
|
|
// Value implements the driver.Valuer interface. Every secret-bearing field
|
|
// (Cube.APIKey, E2B.APIKey, all EnvVars values, and injected header values) is
|
|
// encrypted before persisting. EnvVars are included because environment
|
|
// variables routinely carry credentials, and their values are handed to tenant
|
|
// scripts verbatim. The receiver is never mutated: nested structs and the map
|
|
// are copied first.
|
|
func (c *TenantSandboxConfig) Value() (driver.Value, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
cp := *c
|
|
key := utils.GetAESKey()
|
|
|
|
encrypt := func(plain string) string {
|
|
if plain == "" || key == nil {
|
|
return plain
|
|
}
|
|
encrypted, err := utils.EncryptAESGCM(plain, key)
|
|
if err != nil {
|
|
return plain
|
|
}
|
|
return encrypted
|
|
}
|
|
|
|
if c.Cube != nil {
|
|
cube := *c.Cube
|
|
cube.APIKey = encrypt(cube.APIKey)
|
|
cp.Cube = &cube
|
|
}
|
|
if c.E2B != nil {
|
|
e2b := *c.E2B
|
|
e2b.APIKey = encrypt(e2b.APIKey)
|
|
cp.E2B = &e2b
|
|
}
|
|
// Keys stay readable so operators can still see which variables are set.
|
|
if len(c.EnvVars) > 0 {
|
|
envVars := make(map[string]string, len(c.EnvVars))
|
|
for name, value := range c.EnvVars {
|
|
envVars[name] = encrypt(value)
|
|
}
|
|
cp.EnvVars = envVars
|
|
}
|
|
// Injected headers are the sandbox-side way to call an API without the
|
|
// credential ever entering the sandbox, so their values are secrets.
|
|
if c.Network != nil {
|
|
cp.Network = c.Network.CloneWithSecrets(encrypt)
|
|
}
|
|
|
|
return json.Marshal(&cp)
|
|
}
|
|
|
|
// Scan implements the sql.Scanner interface. Secrets that cannot be decrypted
|
|
// (missing or rotated SYSTEM_AES_KEY) are blanked and logged rather than
|
|
// failing the whole load, matching ModelParameters.Scan — a tenant must stay
|
|
// listable even if its sandbox credentials became unreadable.
|
|
func (c *TenantSandboxConfig) Scan(value interface{}) error {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
b, ok := value.([]byte)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(b, c); err != nil {
|
|
return err
|
|
}
|
|
|
|
decrypt := func(stored, label string) string {
|
|
if stored == "" {
|
|
return ""
|
|
}
|
|
if plain, ok := utils.DecryptStoredSecretLenient(stored); ok {
|
|
return plain
|
|
}
|
|
log.Printf("[crypto] tenant_sandbox_config.%s: decrypt failed "+
|
|
"(SYSTEM_AES_KEY missing/rotated?), treating as unconfigured", label)
|
|
return ""
|
|
}
|
|
|
|
if c.Cube != nil {
|
|
c.Cube.APIKey = decrypt(c.Cube.APIKey, "cube.api_key")
|
|
}
|
|
if c.E2B != nil {
|
|
c.E2B.APIKey = decrypt(c.E2B.APIKey, "e2b.api_key")
|
|
}
|
|
for name, stored := range c.EnvVars {
|
|
c.EnvVars[name] = decrypt(stored, "env_vars."+name)
|
|
}
|
|
if c.Network != nil {
|
|
// CloneWithSecrets has no rule/header context, so rotated-key failures
|
|
// share one label rather than identifying the individual credential.
|
|
c.Network = c.Network.CloneWithSecrets(func(stored string) string {
|
|
return decrypt(stored, "network.injected_header")
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}
|