蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #09 主机属性自动应用:根据模块自动下发配置

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #09 主机属性自动应用:根据模块自动下发配置

Iaas + PaaS 混合运维场景中,主机管理存在一个高频痛点:新交付的云主机加入业务模块后,需要根据模块归属手工设置对应的运维属性(如所属业务线运行环境SLA 等级运维负责人),遗漏配置会导致监控告警无法路由、自动化playbook失效、变更审批流无法匹配。更棘手的是——一台主机可能同时属于多个模块(既有 PaaS 平台模块,又有 Iaas 底层模块),属性冲突怎么办?

电商业务为例:一台 MySQL 数据库主机,既属于数据库服务模块,又属于高可用集群模块,两个模块对运行环境字段要求的值不同(一个是 prod-db,一个是 ha-proxy),系统该如何裁决?

蓝鲸 CMDB 的 Host Apply(主机属性自动应用) 正是为解决这一问题而生。它的设计思路是:给模块绑定属性规则,主机进模块时自动应用,无需人工干预;冲突时由系统按优先级裁决,确保一致性。

本文从源码出发,完整剖析其实现机制。

主机属性自动应用 HostApply 模块属性规则 冲突检测 服务模板联动 异步任务

 src/scene_server/host_server/service/hostapplyrule.go		        ← API 入口层(scene_server)
    src/common/metadata/host_apply_rule.go		                    ← 数据结构定义(metadata)
    src/common/metadata/attribute.go		                            ← CheckAllowHostApplyOnField(字段过滤)
    src/source_controller/coreservice/core/hostapplyrule/rule.go		← 规则 CRUD 核心(coreservice)
    src/source_controller/coreservice/core/hostapplyrule/plan.go		← 执行计划生成 + 实际下发(coreservice)
    src/common/definitions.go		                                    ← HostApplyEnabledField 常量定义

学习重点

  • 必须理解:HostApplyRule 的两条关联路径——模块维度(ModuleID)与服务模板维度(ServiceTemplateID),以及它们在冲突时的优先级规则
  • 必须理解:GenerateApplyPlan 是预览还是执行?两者的区别和边界在哪里
  • 必须理解:ConflictResolver(冲突解决器)的设计意图——为什么冲突不一定阻止下发
  • 应当了解:CheckAllowHostApplyOnField 的字段过滤逻辑,什么字段不允许参与自动应用
  • 应当了解:ExecModuleHostApplyRule 中 HostApplyEnabledField 的作用——"启用"开关的来源

一、问题场景:主机进模块后属性配置靠人工,容易遗漏和出错

想象这样一个场景:SRE 维护一个电商 IaaS/PaaS 平台,"WebServer 模块"下的所有主机需要统一设置 bk_platform_type=ecommercebk_sla_level=P0bk_operator=wangwu 三个属性。手工录入时:

  • 新上线 100 台主机,容易遗漏部分配置
  • 多人协作时规则不统一,张三设 SLA=P1,李四设 SLA=P0
  • 一台主机同时属于两个模块(Web服务模块 + 缓存模块),两个模块的规则冲突时,谁优先?

CMDB 的 Host Apply 就是要把"手工配置"变成"规则驱动"。在模块上配置好规则,主机进模块时自动应用,SRE 只需维护规则,不用管具体主机。

二、核心概念:HostApplyRule 的两条绑定路径

HostApplyRule(主机属性自动应用规则)是 CMDB 中最核心的数据结构。它描述了"哪个模块(或服务模板)下的主机,某个字段应该是什么值"。

这里有一个关键的设计点——规则可以绑定到两个维度:

src/common/metadata/host_apply_rule.go 第 34-51 行(HostApplyRule 结构体):

// host_apply_rule.go L34-51
      type HostApplyRule struct {
        ID       int64 `field:"id" json:"id" bson:"id"`           // 规则主键ID,全局唯一
        BizID    int64 `field:"bk_biz_id" json:"bk_biz_id" bson:"bk_biz_id"`  // 业务ID,标识所属业务
        ModuleID int64 `field:"bk_module_id" json:"bk_module_id" bson:"bk_module_id"` // 模块ID,规则绑定到具体模块
        // NOCC:tosa/linelength(忽略长度)
        ServiceTemplateID int64 `field:"service_template_id" json:"service_template_id" bson:"service_template_id"` // 服务模板ID,与ModuleID互斥
      
        // `id` field of table: `cc_AsstDes`, not the same with bk_property_id
        AttributeID   int64       `field:"bk_attribute_id" json:"bk_attribute_id" bson:"bk_attribute_id"`  // 字段属性ID,关联到具体要设置的字段
        PropertyValue interface{} `field:"bk_property_value" json:"bk_property_value" bson:"bk_property_value"` // 字段值,自动应用时写入主机的该字段
      
        // 通用字段
        Creator         string    `field:"creator"`  // 创建者
        Modifier        string    `field:"modifier"` // 最后修改者
        CreateTime      time.Time `field:"create_time"` // 创建时间
        LastTime        time.Time `field:"last_time"` // 最后修改时间
        SupplierAccount string    `field:"bk_supplier_account"` // 供应商账号
      }

ModuleID 与 ServiceTemplateID 的互斥关系

ModuleIDServiceTemplateID 在同一条规则中互斥——要么绑定到具体模块,要么绑定到服务模板,不能同时非零。这是 src/source_controller/coreservice/core/hostapplyrule/rule.go 第 63-71 行的 validateID 函数明确校验的。

规则存储在 cc_HostApplyRule 表中(definitions.go L101-102 确认),每条规则记录:业务 ID → 模块/模板 ID → 字段 ID → 期望值。

三、数据结构:从源码读出 HostApply 的完整模型

3.1 规则的增删改查

src/common/metadata/host_apply_rule.go 第 58-97 行(规则操作数据结构):

// host_apply_rule.go L58-97
      // 创建规则
      type CreateHostApplyRuleOption struct {
        ModuleID          int64       `json:"bk_module_id,omitempty"`
        ServiceTemplateID  int64       `json:"service_template_id,omitempty"`
        AttributeID       int64       `json:"bk_attribute_id"`
        PropertyValue     interface{} `json:"bk_property_value"`
      }
      
      // 删除规则(含模块场景和服务模板场景两种校验)
      type DeleteHostApplyRuleOption struct {
        ModuleIDs          []int64 `json:"bk_module_ids"`
        ServiceTemplateIDs []int64 `json:"service_template_ids"`
        RuleIDs            []int64 `json:"host_apply_rule_ids"`
      }
      
      // 列表查询
      type ListHostApplyRuleOption struct {
        ApplicationID      int64    `field:"bk_biz_id"`
        ModuleIDs          []int64  `json:"bk_module_ids"`
        ServiceTemplateIDs []int64  `json:"service_template_ids"`
        AttributeIDs       []int64  `json:"bk_attribute_ids"`
        Page               BasePage `json:"page"`
      }
      
      // 批量创建或更新(支持冲突报告)
      type BatchCreateOrUpdateApplyRuleOption struct {
        Rules []CreateOrUpdateApplyRuleOption `field:"host_apply_rules"`
      }

3.2 执行计划数据结构

HostApplyPlanOption 是执行计划生成逻辑的核心数据结构(host_apply_rule.go L222-235),注释写得非常详细,直接引用:

// host_apply_rule.go L222-235
      // HostApplyPlanOption 主机属性自动应用执行计划生成逻辑核心数据结构
      // 设计背景:该数据结构需要支持如下三种场景
      // 1. 应用模块配置到主机属性
      // 2. 编辑模块配置(可能未保存), 预览应用效果(查看是否有冲突)
      // 3. 将主机转移到模块下前预览应用效果(查看是否有冲突)
      // 字段说明
      // - Rules: 主机属性应用规则,由于上述case2的存在,其中 ID 可能为0
      // - HostModules: 主机所有模块信息,case3的存在,导致不能直接从db中查询主机所属模块
      // - ConflictResolvers: 可选参数,用于表示主机属性应用出现冲突时,如何设置应用值,如果未设置则冲突的字段不会被更新
      type HostApplyPlanOption struct {
        Rules             []HostApplyRule             `field:"host_apply_rules"`
        HostModules       []Host2Modules              `field:"host_modules"`
        ConflictResolvers []HostApplyConflictResolver `field:"conflict_resolvers"`
      }
      
      // 单台主机的执行结果
      type OneHostApplyPlan struct {
        HostID         int64     `field:"bk_host_id"`
        CloudInfo      CloudInst `field:"cloud_area"`
        ModuleIDs      []int64   `field:"bk_module_ids"`
        // 预计执行后端主机信息
        ExpectHost     map[string]interface{}   `field:"expect_host"`
        UpdateFields   []HostApplyUpdateField   `field:"update_fields"`
        ConflictFields []HostApplyConflictField `field:"conflicts"`
        // 未解决的冲突字段数
        UnresolvedConflictCount int64 `field:"unresolved_conflict_count"`
      }

四、API 入口:CreateHostApplyRule 的三段式设计

主机属性自动应用规则通过 CreateHostApplyRule 接口创建,该接口位于 src/scene_server/host_server/service/hostapplyrule.go 第 35-70 行。这是整个 Host Apply 功能的 API 入口层,负责接收前端请求并转发到核心服务层处理。

整体采用参数解析 → 事务封装 → 核心调用的三段式设计,这也是 CMDB 其它 API 的通用范式。关键流程:

  • 第一段:参数解析 — 从 URL 路径提取 bizID,从请求体解析 CreateHostApplyRuleOption(包含模块ID/服务模板ID、字段ID、要设置的值)
  • 第二段:事务封装 — 用 AutoRunTxn 包裹核心逻辑,确保"规则写入 DB + IAM 鉴权注册"的原子性
  • 第三段:核心调用 — 委托 CoreService().HostApplyRule().CreateHostApplyRule() 完成实际的规则创建、字段校验、冲突检测等业务逻辑

读源码第 35-70 行(CreateHostApplyRule API):

// hostapplyrule.go L35-70
      func (s *Service) CreateHostApplyRule(ctx *rest.Contexts) {  // HTTP API入口,接收POST请求创建主机属性应用规则
        rid := ctx.Kit.Rid  // 获取请求追踪ID,用于日志关联
      
        bizIDStr := ctx.Request.PathParameter(common.BKAppIDField)  // 从URL路径提取业务ID(如/biz/1/hostapplyrule)
        bizID, err := strconv.ParseInt(bizIDStr, 10, 64)  // 字符串转int64
        if err != nil {  // 参数校验失败
          blog.Errorf("CreateHostApplyRule failed, parse biz id failed, bizIDStr: %s, err: %v,rid:%s", bizIDStr, err, rid)
          ctx.RespAutoError(ctx.Kit.CCError.Errorf(common.CCErrCommParamsInvalid, common.BKAppIDField))  // 返回400参数错误
          return
        }
      
        option := metadata.CreateHostApplyRuleOption{}  // 定义请求体结构体,接收ModuleID/ServiceTemplateID、AttributeID、PropertyValue等参数
        if err := ctx.DecodeInto(&option); err != nil {  // 将JSON请求体解析到option结构体
          ctx.RespAutoError(err)  // 解析失败返回400
          return
        }
      
        var rule metadata.HostApplyRule  // 定义规则返回对象
        txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {  // 开启分布式事务,保证数据一致性
          var err error
          rule, err = s.CoreAPI.CoreService().HostApplyRule().CreateHostApplyRule(ctx.Kit.Ctx, ctx.Kit.Header, bizID, option)  // 调用核心服务层执行实际创建逻辑
          if err != nil {  // 核心服务返回错误
            blog.ErrorJSON("CreateHostApplyRule failed, core service CreateHostApplyRule failed, bizID: %s, "+
              "option: %s, err: %s, rid: %s", bizID, option, err.Error(), rid)
            return err  // 事务自动回滚
          }
          return nil  // 成功,事务自动提交
        })
      
        if txnErr != nil {  // 事务执行失败
          ctx.RespAutoError(txnErr)  // 返回500或业务错误
          return
        }
        ctx.RespEntity(rule)  // 事务成功,200返回创建的规则对象(含规则ID)
      }

AutoRunTxn 包裹:规则创建的原子性保证

整个规则创建过程包裹在 AutoRunTxn 中。这是 CMDB 的标准做法——任何涉及状态变更的操作都必须事务化,保证"规则创建 + IAM 注册"要么同时成功,要么同时回滚。

底层 CreateHostApplyRule 的核心校验在 src/source_controller/coreservice/core/hostapplyrule/rule.go 第 150-213 行:

// rule.go L150-213
      func (p *hostApplyRule) CreateHostApplyRule(kit *rest.Kit, bizID int64,
        option metadata.CreateHostApplyRuleOption) (metadata.HostApplyRule, errors.CCErrorCoder) {
        now := time.Now()
        rule := metadata.HostApplyRule{
          ID:                0,
          BizID:             bizID,
          AttributeID:       option.AttributeID,
          ModuleID:          option.ModuleID,
          ServiceTemplateID: option.ServiceTemplateID,
          PropertyValue:     option.PropertyValue,
          Creator:           kit.User,
          Modifier:          kit.User,
          CreateTime:        now,
          LastTime:          now,
          SupplierAccount:   kit.SupplierAccount,
        }
      
        // Step1: 校验 ModuleID 和 ServiceTemplateID 互斥(definitions.go L101-102 表名)
        if err := p.validateID(kit, bizID, rule.ModuleID, rule.ServiceTemplateID); err != nil {
          return rule, err
        }
      
        // Step2: 校验属性是否存在且可被自动应用
        attribute, ccErr := p.getHostAttribute(kit, bizID, rule.AttributeID)
        if ccErr != nil {
          return rule, ccErr
        }
      
        // Step3: 校验属性值的合法性(如枚举值范围)
        rawError := attribute.Validate(kit.Ctx, option.PropertyValue, common.BKPropertyValueField)
        if rawError.ErrCode != 0 {
          ccErr := rawError.ToCCError(kit.CCError)
          return rule, ccErr
        }
      
        // Step4: 生成 ID 并写入 cc_HostApplyRule 表
        id, err := mongodb.Client().NextSequence(kit.Ctx, common.BKTableNameHostApplyRule)
        rule.ID = int64(id)
        if err := mongodb.Client().Table(common.BKTableNameHostApplyRule).Insert(kit.Ctx, rule); err != nil {
          return rule, kit.CCError.CCError(common.CCErrCommDBInsertFailed)
        }
      
        return rule, nil
      }

五、规则校验:哪些主机字段可以被自动应用?

并非所有主机字段都可以被自动应用。CMDB 通过 CheckAllowHostApplyOnField 函数(attribute.go L1745-1758)控制哪些字段可以参与规则。

src/common/metadata/attribute.go 第 1706-1717 行和第 1745-1758 行:

// attribute.go L1706-1717
      // HostApplyFieldMap:字段级白名单/黑名单
      var HostApplyFieldMap = map[string]bool{
        common.BKOperatorField:        true,   // 允许:运维负责人
        common.BKBakOperatorField:     true,   // 允许:备份运维负责人
        "bk_state":                    true,   // 允许:状态
        "bk_sla":                      true,   // 允许:SLA 等级
        common.BKHostInnerIPField:     false,  // 禁止:主机内网 IP(不允许改)
        common.BKHostOuterIPField:     false,  // 禁止:主机外网 IP
        common.BKAssetIDField:         false,  // 禁止:资产编号
        common.BKSNField:              false,  // 禁止:序列号
        "bk_comment":                  false,  // 禁止:备注
        "bk_service_term":             false,  // 禁止:保修期
      }
      
      // attribute.go L1745-1758
      // CheckAllowHostApplyOnField 检查字段是否能用于主机属性自动应用
      func CheckAllowHostApplyOnField(field *Attribute) bool {
        if !field.IsEditable {
          return false  // 不可编辑的字段,一律禁止
        }
        // 屏蔽表格字段
        if field.PropertyType == common.FieldTypeInnerTable {
          return false  // 表格类型字段禁止
        }
        if allow, exist := HostApplyFieldMap[field.PropertyID]; exist == true {
          return allow  // 白名单/黑名单优先
        }
        return true  // 其余可编辑字段默认可参与
      }

HostApplyFieldMap 的设计意图

这个 Map 解决了一个实际问题:某些字段虽然技术上可编辑(IsEditable=true),但业务上不允许自动修改。比如主机 IP 是核心标识,改了会导致监控断路;资产编号由财务系统管理,CMDB 不应覆盖。
白名单(true)和黑名单(false)并存,兜底策略是"未声明的可编辑字段默认可参与"——这样新增自定义字段时无需改代码。

六、执行链路:ExecModuleHostApplyRule 的全流程

ExecModuleHostApplyRule 是模块维度主机属性自动应用的真正执行入口。它做了三件事:启用模块的 HostApply → 保存规则 → 立即应用。

src/scene_server/host_server/service/hostapplyrule.go 第 714-803 行:

// hostapplyrule.go L714-803
      // ExecModuleHostApplyRule 主机自动应用规则执行入口(模块维度)
      func (s *Service) ExecModuleHostApplyRule(ctx *rest.Contexts) {
        rid := ctx.Kit.Rid
      
        planReq := new(metadata.HostApplyModulesOption)
        if err := ctx.DecodeInto(planReq); err != nil {
          ctx.RespAutoError(err)
          return
        }
      
        // 查符合条件的具体主机 ID
        hostIDs, err := s.getHostIDByCondition(ctx.Kit, planReq.BizID, planReq.ModuleIDs, planReq.HostIDs)
        if err != nil {
          ctx.RespAutoError(err)
          return
        }
      
        txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
          // 阶段1:将模块的 host_apply_enabled 设为 true(definitions.go L378)
          op := &metadata.UpdateOption{
            Condition: map[string]interface{}{
              common.BKModuleIDField: map[string]interface{}{common.BKDBIN: planReq.ModuleIDs}},
            Data: map[string]interface{}{common.HostApplyEnabledField: true},
          }
          _, err := s.Engine.CoreAPI.CoreService().Instance().UpdateInstance(ctx.Kit.Ctx, ctx.Kit.Header,
            common.BKInnerObjIDModule, op)
          if err != nil {
            return err
          }
      
          // 阶段2:批量创建或更新规则
          rulesOption := make([]metadata.CreateOrUpdateApplyRuleOption, 0)
          for _, rule := range planReq.AdditionalRules {
            rulesOption = append(rulesOption, metadata.CreateOrUpdateApplyRuleOption{
              AttributeID:   rule.AttributeID,
              ModuleID:      rule.ModuleID,
              PropertyValue: rule.PropertyValue})
          }
          saveRuleOp := metadata.BatchCreateOrUpdateApplyRuleOption{Rules: rulesOption}
          if _, ccErr := s.CoreAPI.CoreService().HostApplyRule().BatchUpdateHostApplyRule(
            ctx.Kit.Ctx, ctx.Kit.Header, planReq.BizID, saveRuleOp); ccErr != nil {
            return ccErr
          }
      
          // 阶段3:删除被移除的规则
          if len(planReq.RemoveRuleIDs) > 0 {
            removeOp := metadata.DeleteHostApplyRuleOption{
              RuleIDs:   planReq.RemoveRuleIDs,
              ModuleIDs: planReq.ModuleIDs}
            if ccErr := s.CoreAPI.CoreService().HostApplyRule().DeleteHostApplyRule(
              ctx.Kit.Ctx, ctx.Kit.Header, planReq.BizID, removeOp); ccErr != nil {
              return ccErr
            }
          }
          return nil
        })
        if txnErr != nil {
          ctx.RespAutoError(&metadata.RespError{Msg: txnErr})
          return
        }
      
        // 跳过下发的情况:changed=false、仅删除规则、或无符合条件主机
        if !planReq.Changed || len(planReq.AdditionalRules) == 0 || len(hostIDs) == 0 {
          ctx.RespEntity(nil)
          return
        }
      
        // 阶段4:实际将属性下发到主机(不在事务内,已成功的更新无需回滚)
        ctx.Kit.Header.Del(common.TransactionIdHeader)
      
        attributes := make([]metadata.HostAttribute, 0)
        for _, rule := range planReq.AdditionalRules {
          attributes = append(attributes, metadata.HostAttribute{
            AttributeID:   rule.AttributeID,
            PropertyValue: rule.PropertyValue})
        }
      
        if err = s.updateHostApplyByRule(ctx.Kit, attributes, hostIDs); err != nil {
          ctx.RespAutoError(err)
          return
        }
        ctx.RespEntity(nil)
      }

为什么阶段4(属性下发)不在事务内?

代码注释里写了:"update host operation is not done in a transaction, since the successfully updated hosts need not roll back"。属性下发到主机是"幂等"的——重复下发同样值不会产生副作用。因此即使部分主机更新失败,也不应该让已成功的回滚。如果需要回滚,应当通过审计日志 + 手动补偿处理。

七、执行计划生成:GenerateApplyPlan 的冲突检测逻辑

GenerateApplyPlan(plan.go L34-169)是 Host Apply 的核心计算引擎。它接收"规则列表 + 主机-模块关系",输出每个主机的"需要更新的字段 + 冲突字段"。

这里有两个关键设计点:冲突检测多模块优先级

7.1 冲突检测:同一字段被多个模块要求不同值

src/source_controller/coreservice/core/hostapplyrule/plan.go 第 310-409 行(getOneHostApplyPlan 核心逻辑):

// plan.go L310-409(getOneHostApplyPlan 片段)
      func (p *hostApplyRule) getOneHostApplyPlan(kit *rest.Kit, attrRules map[int64][]metadata.HostApplyRule,  // 核心函数:为单台主机计算属性应用计划
        attrMap map[int64]metadata.Attribute, hostID int64, host map[string]interface{}, moduleIDs []int64,  // attrMap: 字段ID→字段定义, host: 当前主机数据, moduleIDs: 主机所在模块列表
        resolverMap map[int64]interface{}) (metadata.OneHostApplyPlan, errors.CCErrorCoder) {  // resolverMap: 冲突解决器映射
      
        plan := metadata.OneHostApplyPlan{  // 初始化应用计划结构体
          HostID:     hostID,  // 目标主机ID
          ModuleIDs:  moduleIDs,  // 该主机所在的所有模块
          ExpectHost: host,  // 期望的主机属性(初始为当前值,后续会被修改)
          ConflictFields: make([]metadata.HostApplyConflictField, 0),  // 冲突字段列表(尚未解决的冲突)
          UpdateFields:   make([]metadata.HostApplyUpdateField, 0),  // 待更新字段列表(需要写入的变更)
        }
      
        for attributeID, targetRules := range attrRules {  // 遍历所有需要处理的字段及其关联规则
          attribute, need := preCheckRules(targetRules, attributeID, attrMap, "")  // 前置检查:字段是否存在、规则是否有效
          if !need {  // 不需要处理(如字段不存在或规则已失效)
            continue
          }
          propertyIDField := attribute.PropertyID  // 获取字段的API标识(如"bk_os_name")
          originalValue, ok := host[propertyIDField]  // 从主机当前数据中获取该字段的现有值
          if !ok {  // 字段在主机上尚未设置
            originalValue = nil
          }
          expectValue := originalValue  // 期望值初始为主机的当前值
      
          // 遍历该主机所在所有模块的同类规则,找第一个不相等的
          conflictedStillExist, needChange := false, false  // conflictedStillExist: 是否存在未解决冲突, needChange: 是否需要变更
          for _, rule := range targetRules {  // 遍历同一字段在多个模块上的所有规则
            isEqual, err := isRuleEqualOrNot(attribute.PropertyType, expectValue, rule.PropertyValue)  // 比较期望值与规则值是否相等(考虑字段类型)
            if err != nil {
              return metadata.OneHostApplyPlan{}, err
            }
            if isEqual {  // 期望值已等于规则值,无需处理
              continue
            }
            needChange = true  // 发现需要变更
            expectValue = rule.PropertyValue   // 覆盖expectValue为最新规则值
            conflictedStillExist = true        // 标记存在潜在冲突(多个模块规则值不一致)
            if propertyValue, exist := resolverMap[attribute.ID]; exist {  // 检查是否有冲突解决器
              conflictedStillExist = false    // 有解决器时,冲突被消除
              expectValue = propertyValue  // 使用解决器指定的值
            }
            plan.ConflictFields = append(plan.ConflictFields, metadata.HostApplyConflictField{  // 记录冲突字段信息
              AttributeID:             attributeID,  // 冲突的字段ID
              PropertyID:              propertyIDField,  // 字段API标识
              PropertyValue:           originalValue,  // 主机当前的原始值
              Rules:                   targetRules,  // 触发冲突的所有规则
              UnresolvedConflictExist: conflictedStillExist,  // 冲突是否已解决
            })
            break  // 找到第一个不相等的规则后跳出
          }
          if !needChange {  // 所有规则值都等于当前值,无需变更
            continue
          }
          if conflictedStillExist {  // 存在未解决的冲突,计数+1
            plan.UnresolvedConflictCount += 1
          }
          // 校验值合法性后再写入
          plan.ExpectHost[propertyIDField] = expectValue  // 将期望值写入计划的主机快照
          plan.UpdateFields = append(plan.UpdateFields, metadata.HostApplyUpdateField{  // 记录需要更新的字段
            AttributeID:   attributeID,  // 字段ID
            PropertyID:    propertyIDField,  // 字段API标识
            PropertyValue: expectValue,  // 要写入的新值
          })
        }
        return plan, nil  // 返回完整的应用计划
      }

7.2 多模块优先级:服务模板规则 > 模块规则

读 plan.go 第 465-567 行(getModuleIDsAndSrvTempIDs + getFinalRules):

// plan.go L465-567(优先级逻辑核心)
      func getModuleIDsAndSrvTempIDs(kit *rest.Kit, modules []metadata.ModuleInst) (
        enableModuleMap map[int64]struct{}, haveHostApplyModuleIDs []int64,
        srvTempModulesMap map[int64][]int64, errors.CCErrorCoder) {
      
        for _, module := range modules {
          if module.ServiceTemplateID != 0 {
            srvTemplateIDs = append(srvTemplateIDs, module.ServiceTemplateID)
          }
          moduleIDHostApplyEnabledMap[module.ModuleID] = module.HostApplyEnabled
        }
      
        // 找出服务模板中 host_apply_enabled=true 的模板
        enableSrvTempMap := make(map[int64]struct{})
        if len(srvTemplateIDs) > 0 {
          filter := map[string]interface{}{
            common.BKFieldID:             map[string]interface{}{common.BKDBIN: srvTemplateIDs},
            common.HostApplyEnabledField: true,  // definitions.go L378
          }
          // ...
        }
      
        // enableModuleMap:真正需要应用规则的主机所在模块集合
        // 服务模板规则优先级更高,先加入 enableModuleMap
        for _, module := range modules {
          if _, ok := existSrvTempIDsMap[module.ServiceTemplateID]; ok {
            srvTempModulesMap[module.ServiceTemplateID] = append(...)
            enableModuleMap[module.ModuleID] = struct{}{}
            continue
          }
          // 模块维度 host_apply_enabled=true 的模块规则
          if moduleIDHostApplyEnabledMap[module.ModuleID] {
            enableModuleMap[module.ModuleID] = struct{}{}
          }
        }
        // ...
      }
      
      // getFinalRules:服务模板规则 + 模块规则合并(模板规则优先)
      func (p *hostApplyRule) getFinalRules(...) []metadata.HostApplyRule {
        // 先拿服务模板规则
        if len(serviceTemplateIDs) > 0 {
          // ... 模板规则先加入 finalRules
        }
        // 再拿模块规则(同名属性会被模板规则覆盖)
        if len(haveHostApplyIDs) > 0 {
          // ... 模块规则追加到 finalRules
        }
        return finalRules
      }

优先级结论(源码可证):

当一台主机同时属于多个模块,且这些模块对同一字段有不同规则时:服务模板规则 > 模块规则。体现在 getFinalRules 中,服务模板规则先被加入 finalRules,模块规则追加在后。后续遍历时,同一 AttributeID 的规则只取第一个匹配(即模板规则)。

八、服务模板维度:GenerateServiceTemplateApplyPlan 的特殊处理

服务模板维度的 Host Apply 遵循"模板 → 模块 → 主机"的传播链:规则配置在服务模板上,自动下发到所有绑定了该模板的模块,再应用到这些模块下的主机。

src/scene_server/host_server/service/hostapplyrule.go 第 1114-1196 行(generateServiceTemplateApplyPlan):

// hostapplyrule.go L1114-1196
      func (s *Service) generateServiceTemplateApplyPlan(
        kit *rest.Kit, option *metadata.HostApplyServiceTemplateOption) (
        metadata.HostApplyPlanResult, errors.CCErrorCoder) {
      
        // Step1: 找出服务模板对应的最终 rule(模板本身 + AdditionalRules 覆盖)
        rules, err := s.findSrvTemplateRule(kit, option.BizID, option.ServiceTemplateIDs)
        if err != nil {
          return metadata.HostApplyPlanResult{}, err
        }
        templateRules := getFinalRule(rules, &option.HostApplyPlanBase)
      
        // Step2: 找出所有绑定了这些服务模板的模块
        moduleRes, err := s.getModuleRelateHostApply(kit, option.BizID, nil, option.ServiceTemplateIDs)
        // 返回 ModuleInst 列表,包含每个模块的 ModuleID、HostApplyEnabled、ServiceTemplateID
      
        // Step3: 将模板的 rule "复制" 到每个模块(生成模块维度的 finalRules)
        moduleIDs := make([]int64, 0)
        tempToModules := make(map[int64][]int64)
        for _, module := range moduleRes {
          moduleIDs = append(moduleIDs, module.ModuleID)
          tempToModules[module.ServiceTemplateID] = append(tempToModules[module.ServiceTemplateID], module.ModuleID)
        }
      
        finalRules := make([]metadata.HostApplyRule, 0)
        for _, rule := range templateRules {
          moduleIDs, exist := tempToModules[rule.ServiceTemplateID]
          if !exist {
            continue
          }
          for _, moduleID := range moduleIDs {
            rule.ModuleID = moduleID   // 模板 rule 的 ModuleID 替换为具体模块 ID
            finalRules = append(finalRules, rule)
          }
        }
      
        // Step4: 查主机-模块关系
        relationReq := &metadata.HostModuleRelationRequest{
          ApplicationID: option.BizID,
          ModuleIDArr:   moduleIDs,
          Page:          metadata.BasePage{Limit: common.BKNoLimit},
        }
        hostRelations, _ := s.CoreAPI.CoreService().Host().GetHostModuleRelation(...)
      
        // Step5: 复用 GenerateApplyPlan 生成执行计划
        planOption := metadata.HostApplyPlanOption{
          Rules:       finalRules,
          HostModules: hostModules,
        }
        planResult, ccErr := s.CoreAPI.CoreService().HostApplyRule().GenerateApplyPlan(...)
        return planResult, nil
      }

服务模板维度的 Host ApplyEnabled 来自服务模板本身

注意在 plan.go 的 getModuleIDsAndSrvTempIDs 中,服务模板维度的启用状态来自 ServiceTemplate.HostApplyEnabled(hostapplyrule.go L1068),而不是模块的 host_apply_enabled 。这意味着可以在服务模板层面统一开关,再通过模板传播到所有绑定模块。

九、源码视角:从源码读出三层设计思想

我理解源码的意思是说

蓝鲸 CMDB 的主机属性自动应用体系,从源码里可以读出三个设计原则。SRE 理解这些原则后,能更准确地预判系统行为。

源码视角一:两条绑定路径 + 优先级覆盖

src/common/metadata/host_apply_rule.go L34-51(HostApplyRule)和 plan.go L465-567(优先级逻辑),会发现规则可以绑定到模块(ModuleID)或服务模板(ServiceTemplateID),不能同时非零。在 getFinalRules 中,服务模板规则先加入列表,模块规则后追加,遍历时同名字段只取第一个匹配——实现了"模板规则优先于模块规则"的覆盖语义。

源码视角二:预览与执行分离,冲突不阻止下发

src/source_controller/coreservice/core/hostapplyrule/plan.go L34-169(GenerateApplyPlan) 和 L654-704(carryOutPlan),会发现 CMDB 将"生成计划"和"执行计划"严格分离。 GenerateApplyPlan 负责冲突检测(记录哪些字段存在冲突、冲突值是什么),但不会阻止下发。ConflictResolvers 是可选的——即使不提供冲突解决器,未解决冲突的字段会留在 ConflictFields 中,但其他无冲突的字段依然可以正常下发。

源码视角三:属性下发不走事务,已成功的不回滚

src/scene_server/host_server/service/hostapplyrule.go L714-803(ExecModuleHostApplyRule),会发现规则创建和启用(阶段1-3)包裹在 AutoRunTxn 中,但属性实际下发(阶段4)不在事务内。代码注释明确说明:"successfully updated hosts need not roll back"——属性下发是幂等的,重复下发同样值无副作用。

源码视角四:字段级白名单,IP/SN 等核心字段禁止自动修改

src/common/metadata/attribute.go L1706-1758(HostApplyFieldMap + CheckAllowHostApplyOnField),会发现 CMDB 用"黑名单优先 + 默认可编辑"的方式控制哪些字段可以参与自动应用。IP、资产编号、SN 等字段明确 false,运维负责人、SLA 等字段明确 true,其余自定义字段默认参与。

源码视角总结:5 个核心设计原则

  • 2 条绑定路径:ModuleID 与 ServiceTemplateID 互斥,服务模板优先
  • 1 个预览-执行分离:GenerateApplyPlan 只算不写,Exec 才实际改数据
  • 1 个非事务下发:属性下发不回滚,已成功的不因后续失败而撤销
  • 1 个字段白名单:IP/SN/资产编号禁止,运维负责人/SLA 允许,其余默认
  • 1 个异步任务标记:SyncModuleHostApplyTaskFlag=module_host_apply_sync(definitions.go L1634)

避坑提醒(源码视角):

  • 不要在内置模块(空闲机/故障机)上启用 Host Apply:内置模块的 Default != 0(process.go L2109 上下文),Host Apply 主要用于业务模块,配置在空闲机上没有实际意义
  • 不要跳过 CheckAllowHostApplyOnField 直接将任意字段加入规则:IP/SN/资产编号等字段禁止自动修改,强行写入规则会在 RunHostApplyOnHosts(plan.go L569)的 canUpdate hook 中被拒绝
  • 不要将模块维度和模板维度的规则混淆使用:BatchUpdateHostApplyRule(rule.go L584-693)会同时创建/更新两类规则,但 getFinalRules(plan.go L526-567)会按优先级合并,混用会导致预期外的覆盖
  • 主机进模块时若想预览冲突,必须用 GenerateModuleApplyPlan:直接用 ExecModuleHostApplyRule 会直接下发,无法预览冲突后再决定是否执行

十、FAQ 20 问

分组说明:以下 20 组 Q&A 覆盖了 Host Apply 从配置到触发、从冲突到校验的核心问题。

Q1. HostApplyRule 和模块的 host_apply_enabled 是什么关系?

规则本身是"配置内容",host_apply_enabled 是"启用开关"。只有当模块(或服务模板)的 host_apply_enabled=true 时,HostApplyRule 才会被实际应用到主机。规则存在但开关关闭,不会有任何效果。

Q2. 为什么需要 GenerateModuleApplyPlan?它和 ExecModuleHostApplyRule 有什么区别?

GenerateModuleApplyPlan 只计算不写入——它输出每个主机需要更新的字段和冲突情况,供 SRE 预览后决定是否执行。ExecModuleHostApplyRule 才会真正将属性下发到 cc_HostBaseAttr 表。两者是"预览"和"执行"的关系。

Q3. 一台主机同时属于两个模块,两个模块对同一字段有不同规则时,谁生效?

在 getFinalRules(plan.go L526-567)中,服务模板规则先加入 finalRules,模块规则后追加。遍历时同 AttributeID 的规则只取第一个,即服务模板规则优先于模块规则。如果两个规则都来自模块维度,则取遍历到的第一个(不确定性)。

Q4. 主机属性自动应用在哪个时机触发?

有三个触发时机:1)SRE 手动调用 ExecModuleHostApplyRule;2)主机转移进模块时(TransferHostWithAutoClearServiceInstance 会触发 RunHostApplyOnHosts,plan.go L569);3)规则变更时(UpdateModuleHostApplyRule 创建异步任务,hostapplyrule.go L684-710,任务标记为 module_host_apply_sync)。

Q5. 哪些主机字段不能参与自动应用?

HostApplyFieldMap(attribute.go L1706-1717)明确禁止:主机内网 IP(bk_host_innerip)、主机外网 IP(bk_host_outerip)、资产编号(bk_asset_id)、序列号(bk_sn)、备注(bk_comment)、保修期(bk_service_term)。禁止的原因是这些字段有外部权威来源(网络监控系统、财务系统),CMDB 自动修改会破坏数据一致性。

Q6. ConflictResolver 是怎么工作的?

ConflictResolver(host_apply_rule.go L204-208)是一个"冲突时的替代值"。当遍历规则发现 expectValue 与当前值不相等时,如果有对应 AttributeID 的 resolver,就用 resolver 的值替代,同时将 conflictedStillExist 设为 false。相当于 SRE 提前告诉系统:"如果冲突,按这个值来"。

Q7. BatchCreateOrUpdateHostApplyRule 的错误处理机制是怎样的?

BatchUpdateHostApplyRule(rule.go L584-693)对每条规则逐个尝试创建或更新(Upsert 逻辑:先查 count > 0 则更新,否则创建),错误不阻断后续规则。最终返回一个 Items 数组,每个 item 有自己的错误状态,SRE 可以通过遍历 Items 判断哪些规则失败、哪些成功。

Q8. 主机属性自动应用是否支持回滚?

不直接支持自动回滚。ExecModuleHostApplyRule(hostapplyrule.go L714)的属性下发阶段(阶段4)不在事务内,已更新成功的主机不会因为后续主机失败而回滚。但 CMDB 的 auditlog 模块会记录每次属性变更,SRE 可以通过审计日志查到变更前的值并手动补偿。

Q9. 模块的 host_apply_enabled 字段在哪里定义的?

host_apply_enabled 在 definitions.go L378 定义常量 HostApplyEnabledField = "host_apply_enabled"。这个字段存在于 cc_ModuleBase 表中,控制该模块是否启用主机属性自动应用。当 host_apply_enabled=false 时,即使有 HostApplyRule,也不会触发应用。

Q10. 服务模板维度和模块维度的 Host Apply,谁更适合生产使用?

一般推荐服务模板维度。当大量模块需要相同规则时(如 100 个 Tomcat 模块都需要 bk_sla=P0),在服务模板上配置一次,规则自动传播到所有绑定该模板的模块,避免重复配置。模块维度适合少量特殊场景的差异化配置。

Q11. UpdateModuleHostApplyRule 和 ExecModuleHostApplyRule 有什么区别?

UpdateModuleHostApplyRule(hostapplyrule.go L665)用于"更新规则并触发异步任务"——它创建 task_server 任务(task_flag=module_host_apply_sync,definitions.go L1634),在后台异步执行,主机属性变更不阻塞。ExecModuleHostApplyRule 用于"同步执行"——SRE 等待结果返回,适合小规模即时生效场景。

Q12. 为什么 HostApplyRule 的 AttributeID 是 bk_attribute_id 而不是 bk_property_id?

注释里有说明:"id field of table: cc_AsstDes, not the same with bk_property_id"(host_apply_rule.go L41-42)。bk_attribute_id 是 cc_ObjAttDes 表中的主键 ID,bk_property_id 是字段的英文名。CMDB 用 ID 而非字符串做关联键,是为了避免重命名字段时需要级联更新所有规则记录。

Q13. 如果主机已有某个字段的值和规则相同,还需要更新吗?

不需要。getOneHostApplyPlan(plan.go L342-350)会先用 isRuleEqualOrNot 比较当前值和规则期望值,只有不相等时才设置 needChange=true,才会产生 UpdateFields。如果值已经一致,该字段不会出现在更新列表中。

Q14. 主机属性自动应用会生成审计日志吗?

会。属性下发走的是 Instance().UpdateInstance(plan.go L684),CMDB 的 auditlog 中间件会拦截所有 Instance Update 操作并生成审计日志,记录变更前后的值、操作人、时间戳。

Q15. ServiceTemplate.HostApplyEnabled 和 Module.HostApplyEnabled 有什么区别?

两者控制不同维度。Module.HostApplyEnabled 控制"这个模块的规则是否生效"。ServiceTemplate.HostApplyEnabled 控制"这个服务模板的规则是否向绑定模块传播"。在 getModuleIDsAndSrvTempIDs(plan.go L465)中,会先查模板级别的 HostApplyEnabled,如果为 true 才把对应模块加入 enableModuleMap。

Q16. 为什么字段值比较需要区分 int/float/time/enum 等类型?

因为 MongoDB 存储和 Go 反序列化后的类型可能不同。isRuleEqualOrNot(plan.go L238-292)针对不同 PropertyType 做类型转换:int/float 要转成统一数值类型才能比较;time 要从 primitive.DateTime 转为 time.Time 再比较;organization 类型原生是 primitive.A 数组,需要展开逐元素比较。这确保了"字面值相同但类型不同"不会误判为相等。

Q17. 主机属性自动应用支持分页限制吗?

ListHostApplyRule(hostapplyrule.go L206-209)校验 BKMaxLimitSize(约 500 条),防止一次查询规则数过多。但主机的属性下发(updateHostPlan,hostapplyrule.go L344-428)没有显式的单次上限,因为属性下发是批量 UPDATE 语句,由 MongoDB 的写入限制自然约束。

Q18. 规则变更后,已经应用过的主机会自动重新应用吗?

需要手动触发。UpdateModuleHostApplyRule 会创建异步任务执行,但不会自动触发历史主机的重新应用。SRE 需要主动调用 GenerateModuleApplyPlan 预览后,再用 ExecModuleHostApplyRule 或 UpdateModuleHostApplyRule 触发重新应用。

Q19. 为什么 HostApplyFieldMap 里 bk_operator 允许而 bk_host_innerip 禁止?

运维负责人(bk_operator)是"业务管理属性",随模块归属变化是合理的,SRE 需要明确谁负责哪台机器。主机 IP 是"基础设施标识",修改会导致监控/告警断路,由网络管理系统权威维护,不应被 CMDB 覆盖。这个边界设计体现了"可改则改、不可改则锁"的分层控制理念。

Q20. CC_HostApplyRule 表的主键是什么?唯一索引如何保证?

主键是自增 ID(mongodb.Client().NextSequence,rule.go L197)。唯一索引由 biz_id + module_id/service_template_id + attribute_id 组合构成(DuplicateKey 时返回 CCErrCommDuplicateItem 错误,rule.go L205-207)。这保证了同一模块下同一字段只能有一条规则,避免重复。

Roadmap:后续预告

后续预告

  • #10 批量导入导出:Excel 模板与数据校验——主机批量录入的高效方式
  • #11 异步任务系统:批量操作的进度追踪与回滚——task_server 如何管理 Host Apply 异步任务的生命周期

全篇总结

  • HostApplyRule 的两条绑定路径(ModuleID / ServiceTemplateID)互斥,服务模板规则优先
  • GenerateApplyPlan 是预览,ExecModuleHostApplyRule 是执行,两者严格分离
  • 冲突检测基于 isRuleEqualOrNot 分类型比较,不阻止无冲突字段下发
  • 字段级白名单(HostApplyFieldMap)禁止 IP/SN/资产编号自动修改
  • 属性下发不走事务,已成功的不回滚,通过审计日志支持事后追溯
posted @ 2026-07-05 20:04  左扬  阅读(17)  评论(0)    收藏  举报