Persist agent/secrets settings with masked secrets and runner hot-reload; add maintenance windows, maintpage binary, and zero-downtime listener handoff.
419 lines
15 KiB
Go
419 lines
15 KiB
Go
package sysadmin
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 设置读写错误;HTTP 适配层据此映射状态码。
|
||
var (
|
||
ErrUnknownSetting = errors.New("sysadmin: unknown setting key")
|
||
ErrSettingReadOnly = errors.New("sysadmin: setting is read-only")
|
||
ErrInvalidSettingValue = errors.New("sysadmin: invalid setting value")
|
||
ErrStoreUnavailable = errors.New("sysadmin: settings store unavailable")
|
||
ErrExpectedRevisionRequired = errors.New("sysadmin: expected_revision required")
|
||
ErrSecretUnchanged = errors.New("sysadmin: secret empty means keep existing")
|
||
)
|
||
|
||
// 设置值类型。
|
||
const (
|
||
SettingTypeString = "string"
|
||
SettingTypeBool = "bool"
|
||
SettingTypeNumber = "number"
|
||
)
|
||
|
||
// SettingSpec 白名单注册表条目;只有登记在册的 key 才可读写。
|
||
type SettingSpec struct {
|
||
Key string
|
||
Label string
|
||
Group string
|
||
Type string
|
||
Default any
|
||
Writable bool
|
||
RequiresRestart bool
|
||
Secret bool // true 时 API 只回掩码,审计只存指纹
|
||
HotReload bool // true 时保存后可推送到 PI runner 热更新
|
||
EnvVar string // 可选 env 中间层:DB > env > default
|
||
Description string
|
||
}
|
||
|
||
// Setting 设置视图,字段对齐前端 camelCase 契约。
|
||
type Setting struct {
|
||
Key string `json:"key"`
|
||
Label string `json:"label"`
|
||
Group string `json:"group"`
|
||
Type string `json:"type"`
|
||
Value any `json:"value"`
|
||
Default any `json:"default"`
|
||
Writable bool `json:"writable"`
|
||
RequiresRestart bool `json:"requiresRestart"`
|
||
Secret bool `json:"secret,omitempty"`
|
||
HotReload bool `json:"hotReload,omitempty"`
|
||
HasValue bool `json:"hasValue,omitempty"`
|
||
Masked string `json:"masked,omitempty"`
|
||
Description string `json:"description"`
|
||
Revision int64 `json:"revision"`
|
||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||
UpdatedBy *int64 `json:"updatedBy,omitempty"`
|
||
ApplyStatus string `json:"applyStatus,omitempty"` // ok | pending | error | n/a
|
||
ApplyError string `json:"applyError,omitempty"`
|
||
}
|
||
|
||
// SettingUpdate 写入入参;仅版本化策略 key 要求 ExpectedRevision。
|
||
type SettingUpdate struct {
|
||
Key string
|
||
Value json.RawMessage
|
||
UpdatedBy int64
|
||
ExpectedRevision *int64
|
||
RequestID string
|
||
}
|
||
|
||
// defaultSettingSpecs 白名单:UI/业务策略 + agent/secrets(.env 类)。
|
||
//
|
||
// Wave 5 X-4: ui.* / chat.* / tasks.* / kb.* / notifications.* 部分键待消费方接线。
|
||
// 预期消费方:
|
||
// - ui.maintenance_notice → 站点顶部维护公告横幅(P2 maintenance 接管)
|
||
// - ui.read_only_mode → 写操作中间件 / 前端写入口阻断
|
||
func defaultSettingSpecs() []SettingSpec {
|
||
return []SettingSpec{
|
||
{Key: "ui.maintenance_notice", Label: "维护公告", Group: "ui", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, Description: "全站维护公告横幅文案;空字符串表示关闭"},
|
||
{Key: "ui.read_only_mode", Label: "只读模式提示", Group: "ui", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "开启后前端进入只读提示模式,阻断写操作入口"},
|
||
{Key: "chat.default_session_role", Label: "新会话默认职责", Group: "chat", Type: SettingTypeString, Default: "engineer", Writable: true, RequiresRestart: false, Description: "未显式指定职责时新会话的默认角色"},
|
||
{Key: "tasks.default_auto_repair", Label: "任务默认自动修复", Group: "tasks", Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false, Description: "独立任务运行未传 auto_repair 时的默认值"},
|
||
{Key: "kb.default_search_limit", Label: "KB 检索默认条数", Group: "kb", Type: SettingTypeNumber, Default: 8.0, Writable: true, RequiresRestart: false, Description: "知识库检索未指定 limit 时的默认返回条数"},
|
||
{Key: "notifications.stage_advance_reminder", Label: "阶段推进提醒总开关", Group: "notification", Type: SettingTypeBool, Default: true, Writable: true, RequiresRestart: false, Description: "阶段就绪 24h 提醒的总开关(一期先落机制)"},
|
||
{
|
||
Key: SettingKeyAllowAuthorOnlyApproval, Label: "允许编制人兼任审核批准", Group: "workflow",
|
||
Type: SettingTypeBool, Default: false, Writable: true, RequiresRestart: false,
|
||
Description: "授权新配置:开启后项目创建者可在激活前为文档启用编制人兼任。关闭后禁止新启用/激活兼任规划;已激活实例按冻结职责继续执行,不作为紧急熔断。",
|
||
},
|
||
// agent 组:模型与端点(可热推 runner)
|
||
{Key: "agent.minimax_endpoint_url", Label: "MiniMax Endpoint", Group: "agent", Type: SettingTypeString, Default: "https://api.minimaxi.com/v1", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_ENDPOINT_URL", Description: "MiniMax API 基址"},
|
||
{Key: "agent.minimax_model_id", Label: "MiniMax 模型 ID", Group: "agent", Type: SettingTypeString, Default: "MiniMax-M2.7-highspeed", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_MODEL_ID", Description: "默认模型 ID"},
|
||
{Key: "agent.minimax_api_type", Label: "MiniMax API 类型", Group: "agent", Type: SettingTypeString, Default: "openai-compatible", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "MINIMAX_API_TYPE", Description: "openai-compatible 或原生类型"},
|
||
{Key: "agent.pi_agent_model", Label: "PI Agent 模型", Group: "agent", Type: SettingTypeString, Default: "MiniMax-M2.7-highspeed", Writable: true, RequiresRestart: false, HotReload: true, EnvVar: "PI_AGENT_MODEL", Description: "宿主侧 PI Agent 模型名"},
|
||
// secrets 组:密钥(掩码 + 热推或部分需重启)
|
||
{Key: "secrets.minimax_api_key", Label: "MiniMax API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, HotReload: true, Secret: true, EnvVar: "MINIMAX_API_KEY", Description: "MiniMax API 密钥;空保存表示保持原值"},
|
||
{Key: "secrets.tavily_api_keys", Label: "Tavily API Keys", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: false, HotReload: true, Secret: true, EnvVar: "TAVILY_API_KEYS", Description: "逗号分隔的 Tavily key 池;空保存表示保持原值"},
|
||
{Key: "secrets.embedding_api_key", Label: "Embedding API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "EMBEDDING_API_KEY", Description: "向量 Embedding 密钥(需重启 Go 宿主)"},
|
||
{Key: "secrets.reranker_api_key", Label: "Reranker API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "RERANKER_API_KEY", Description: "Reranker 密钥(需重启 Go 宿主)"},
|
||
{Key: "secrets.mineru_api_key", Label: "MinerU API Key", Group: "secrets", Type: SettingTypeString, Default: "", Writable: true, RequiresRestart: true, HotReload: false, Secret: true, EnvVar: "MINERU_API_KEY", Description: "MinerU 文档解析密钥(需重启 Go 宿主)"},
|
||
}
|
||
}
|
||
|
||
func (s *Service) findSpec(key string) (SettingSpec, bool) {
|
||
for _, spec := range s.specs {
|
||
if spec.Key == key {
|
||
return spec, true
|
||
}
|
||
}
|
||
return SettingSpec{}, false
|
||
}
|
||
|
||
// ListSettings 返回白名单全部设置;读优先级 DB > env > default。
|
||
func (s *Service) ListSettings(ctx context.Context) ([]Setting, error) {
|
||
out := make([]Setting, 0, len(s.specs))
|
||
for _, spec := range s.specs {
|
||
setting, err := s.resolve(ctx, spec)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, setting)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// UpdateSetting 校验并写入白名单设置,返回更新后的视图。
|
||
func (s *Service) UpdateSetting(ctx context.Context, key string, raw json.RawMessage, updatedBy int64) (Setting, error) {
|
||
return s.UpdateSettingEx(ctx, SettingUpdate{Key: key, Value: raw, UpdatedBy: updatedBy})
|
||
}
|
||
|
||
// ListPolicyAudits 返回指定设置键的策略变更审计(最新在前)。
|
||
func (s *Service) ListPolicyAudits(ctx context.Context, key string, limit int) ([]PolicyAudit, error) {
|
||
if key == "" {
|
||
return nil, ErrUnknownSetting
|
||
}
|
||
if _, ok := s.findSpec(key); !ok {
|
||
return nil, ErrUnknownSetting
|
||
}
|
||
if s.store == nil {
|
||
return nil, ErrStoreUnavailable
|
||
}
|
||
return s.store.ListPolicyAudits(ctx, key, limit)
|
||
}
|
||
|
||
// UpdateSettingEx 校验并写入白名单设置;对 SettingKeyAllowAuthorOnlyApproval 强制 CAS + 同事务审计。
|
||
func (s *Service) UpdateSettingEx(ctx context.Context, in SettingUpdate) (Setting, error) {
|
||
spec, ok := s.findSpec(in.Key)
|
||
if !ok {
|
||
return Setting{}, ErrUnknownSetting
|
||
}
|
||
if !spec.Writable {
|
||
return Setting{}, ErrSettingReadOnly
|
||
}
|
||
if s.store == nil {
|
||
return Setting{}, ErrStoreUnavailable
|
||
}
|
||
|
||
raw := in.Value
|
||
if spec.Secret {
|
||
keep, err := secretKeepExisting(raw)
|
||
if err != nil {
|
||
return Setting{}, err
|
||
}
|
||
if keep {
|
||
return s.resolve(ctx, spec)
|
||
}
|
||
}
|
||
|
||
if err := validateSettingValue(spec.Type, raw); err != nil {
|
||
return Setting{}, err
|
||
}
|
||
|
||
if in.Key == SettingKeyAllowAuthorOnlyApproval {
|
||
if in.ExpectedRevision == nil {
|
||
return Setting{}, ErrExpectedRevisionRequired
|
||
}
|
||
if _, _, err := s.store.UpsertCAS(ctx, in.Key, raw, in.UpdatedBy, *in.ExpectedRevision, in.RequestID); err != nil {
|
||
return Setting{}, err
|
||
}
|
||
} else {
|
||
auditAfter := raw
|
||
if spec.Secret {
|
||
auditAfter = secretFingerprintJSON(raw)
|
||
}
|
||
if _, _, err := s.store.UpsertWithAudit(ctx, in.Key, raw, auditAfter, in.UpdatedBy, in.RequestID); err != nil {
|
||
return Setting{}, err
|
||
}
|
||
}
|
||
|
||
setting, err := s.resolve(ctx, spec)
|
||
if err != nil {
|
||
return Setting{}, err
|
||
}
|
||
if spec.HotReload && s.applyFn != nil {
|
||
status, applyErr := s.applyFn(ctx)
|
||
setting.ApplyStatus = status
|
||
if applyErr != nil {
|
||
setting.ApplyError = applyErr.Error()
|
||
}
|
||
} else if spec.RequiresRestart {
|
||
setting.ApplyStatus = "pending_restart"
|
||
} else {
|
||
setting.ApplyStatus = "ok"
|
||
}
|
||
return setting, nil
|
||
}
|
||
|
||
// resolve 按 DB > env > default 解析单个设置的当前值。
|
||
func (s *Service) resolve(ctx context.Context, spec SettingSpec) (Setting, error) {
|
||
setting := Setting{
|
||
Key: spec.Key,
|
||
Label: spec.Label,
|
||
Group: spec.Group,
|
||
Type: spec.Type,
|
||
Value: spec.Default,
|
||
Default: spec.Default,
|
||
Writable: spec.Writable,
|
||
RequiresRestart: spec.RequiresRestart,
|
||
Secret: spec.Secret,
|
||
HotReload: spec.HotReload,
|
||
Description: spec.Description,
|
||
}
|
||
if spec.EnvVar != "" {
|
||
if raw, set := os.LookupEnv(spec.EnvVar); set {
|
||
if v, err := parseSettingValue(spec.Type, raw); err == nil {
|
||
setting.Value = v
|
||
}
|
||
}
|
||
}
|
||
if s.store == nil {
|
||
return finalizeSecretView(setting, spec), nil
|
||
}
|
||
raw, updatedBy, updatedAt, revision, err := s.store.Get(ctx, spec.Key)
|
||
if err != nil {
|
||
if errors.Is(err, ErrSettingNotFound) {
|
||
return finalizeSecretView(setting, spec), nil
|
||
}
|
||
return Setting{}, err
|
||
}
|
||
var value any
|
||
if err := json.Unmarshal(raw, &value); err != nil {
|
||
return Setting{}, fmt.Errorf("sysadmin: decode stored value for %s: %w", spec.Key, err)
|
||
}
|
||
setting.Value = value
|
||
setting.Revision = revision
|
||
setting.UpdatedBy = updatedBy
|
||
setting.UpdatedAt = updatedAt
|
||
return finalizeSecretView(setting, spec), nil
|
||
}
|
||
|
||
// ResolveRawString 返回未掩码的字符串值(供 runner 推送);secret 也返回明文。
|
||
func (s *Service) ResolveRawString(ctx context.Context, key string) (string, error) {
|
||
spec, ok := s.findSpec(key)
|
||
if !ok {
|
||
return "", ErrUnknownSetting
|
||
}
|
||
if s.store != nil {
|
||
raw, _, _, _, err := s.store.Get(ctx, key)
|
||
if err == nil {
|
||
var v string
|
||
if err := json.Unmarshal(raw, &v); err == nil {
|
||
return v, nil
|
||
}
|
||
} else if !errors.Is(err, ErrSettingNotFound) {
|
||
return "", err
|
||
}
|
||
}
|
||
if spec.EnvVar != "" {
|
||
if raw, set := os.LookupEnv(spec.EnvVar); set {
|
||
return raw, nil
|
||
}
|
||
}
|
||
if s, ok := spec.Default.(string); ok {
|
||
return s, nil
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
func finalizeSecretView(setting Setting, spec SettingSpec) Setting {
|
||
if !spec.Secret {
|
||
return setting
|
||
}
|
||
plain := ""
|
||
switch v := setting.Value.(type) {
|
||
case string:
|
||
plain = v
|
||
case nil:
|
||
plain = ""
|
||
default:
|
||
b, _ := json.Marshal(v)
|
||
plain = string(b)
|
||
}
|
||
setting.HasValue = strings.TrimSpace(plain) != ""
|
||
setting.Masked = MaskSecret(plain)
|
||
setting.Value = ""
|
||
return setting
|
||
}
|
||
|
||
// MaskSecret 展示前缀/后缀,中间打码。
|
||
func MaskSecret(plain string) string {
|
||
plain = strings.TrimSpace(plain)
|
||
if plain == "" {
|
||
return ""
|
||
}
|
||
// 多 key 池:分别掩码后拼接
|
||
if strings.ContainsAny(plain, ",;\n") {
|
||
parts := strings.FieldsFunc(plain, func(r rune) bool {
|
||
return r == ',' || r == ';' || r == '\n' || r == ' '
|
||
})
|
||
masked := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
masked = append(masked, maskOne(p))
|
||
}
|
||
return strings.Join(masked, ",")
|
||
}
|
||
return maskOne(plain)
|
||
}
|
||
|
||
func maskOne(s string) string {
|
||
if len(s) < 8 {
|
||
return "****"
|
||
}
|
||
return s[:4] + "..." + s[len(s)-4:]
|
||
}
|
||
|
||
func secretKeepExisting(raw json.RawMessage) (bool, error) {
|
||
trimmed := bytes.TrimSpace(raw)
|
||
if len(trimmed) == 0 || string(trimmed) == "null" {
|
||
return true, nil
|
||
}
|
||
var v string
|
||
if err := json.Unmarshal(raw, &v); err != nil {
|
||
return false, ErrInvalidSettingValue
|
||
}
|
||
return strings.TrimSpace(v) == "", nil
|
||
}
|
||
|
||
func secretFingerprintJSON(raw json.RawMessage) json.RawMessage {
|
||
var v string
|
||
if err := json.Unmarshal(raw, &v); err != nil {
|
||
sum := sha256.Sum256(raw)
|
||
out, _ := json.Marshal(map[string]string{"sha256": hex.EncodeToString(sum[:])})
|
||
return out
|
||
}
|
||
sum := sha256.Sum256([]byte(v))
|
||
out, _ := json.Marshal(map[string]string{"sha256": hex.EncodeToString(sum[:])})
|
||
return out
|
||
}
|
||
|
||
// validateSettingValue 按声明类型校验 JSON 值。
|
||
func validateSettingValue(valueType string, raw json.RawMessage) error {
|
||
if len(raw) == 0 {
|
||
return ErrInvalidSettingValue
|
||
}
|
||
trimmed := bytes.TrimSpace(raw)
|
||
if string(trimmed) == "null" {
|
||
return ErrInvalidSettingValue
|
||
}
|
||
switch valueType {
|
||
case SettingTypeString:
|
||
var v string
|
||
if err := json.Unmarshal(raw, &v); err != nil {
|
||
return ErrInvalidSettingValue
|
||
}
|
||
case SettingTypeBool:
|
||
var v bool
|
||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||
dec.DisallowUnknownFields()
|
||
if err := dec.Decode(&v); err != nil {
|
||
return ErrInvalidSettingValue
|
||
}
|
||
if dec.More() {
|
||
return ErrInvalidSettingValue
|
||
}
|
||
case SettingTypeNumber:
|
||
var v float64
|
||
if err := json.Unmarshal(raw, &v); err != nil {
|
||
return ErrInvalidSettingValue
|
||
}
|
||
default:
|
||
return ErrInvalidSettingValue
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseSettingValue 将 env 字符串按类型解析。
|
||
func parseSettingValue(valueType, raw string) (any, error) {
|
||
switch valueType {
|
||
case SettingTypeString:
|
||
return raw, nil
|
||
case SettingTypeBool:
|
||
v, err := strconv.ParseBool(raw)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return v, nil
|
||
case SettingTypeNumber:
|
||
v, err := strconv.ParseFloat(raw, 64)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return v, nil
|
||
default:
|
||
return nil, ErrInvalidSettingValue
|
||
}
|
||
}
|