Files
caowangrenbo 0fffdd5ccb test: 集成测试改用真 LLDAP 容器;修复两处物化缺陷
- 集成测试重构:syncer e2e/materialize、ldaplib 读路径、lldapgql 全改用本地
  LLDAP 容器(LDAP 3890 / GraphQL /api/graphql 17170),删旧 OpenLDAP 断言
- 修复 1:ActiveUsers 不填 DepartmentIDs(存于关系表),BuildFromMirror 拿到空成员;
  新增 ActiveUsersWithDepts,物化 plan 用之
- 修复 2:deleted 用户重新可见后不回 active,无法重建;RevertToActive 纳入 deleted,
  快照 reconcile 对其置回 active + 作废 token
- 关键安全回归单测保留并配变异验证:未确认不删、非受管不删、admin 永不移出、
  删除最后阶段、绝不碰密码。全量单测+vet+集成全绿
2026-09-29 23:53:24 +08:00

269 lines
7.3 KiB
Go

package syncer
// 两阶段编排器:快照(飞书→镜像)与物化(镜像→LLDAP)。
//
// 这两段共享一把单实例互斥锁,保证快照与物化绝不同时执行(镜像事务化 + 物化
// 全成功才回写的前提),否则物化可能读到半新半旧的镜像污染 LDAP(约束 2)。
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"idsync/internal/config"
"idsync/internal/feishu"
"idsync/internal/ldaplib"
"idsync/internal/lldapgql"
"idsync/internal/mapping"
"idsync/internal/store"
)
// Runner 编排快照与物化。
type Runner struct {
cfg *config.Config
feishu *feishu.Client
ldap *ldaplib.Client
gql *lldapgql.Client
store *store.Store
snapshot *SnapshotRunner
log *slog.Logger
guard Guard
// mu 让快照与物化在单实例内互斥。
mu sync.Mutex
groupIDs map[string]int
}
// NewRunner 构造编排器。
func NewRunner(cfg *config.Config, f *feishu.Client, l *ldaplib.Client, g *lldapgql.Client,
s *store.Store, log *slog.Logger, opts SnapshotOptions) *Runner {
return &Runner{
cfg: cfg,
feishu: f,
ldap: l,
gql: g,
store: s,
snapshot: NewSnapshotRunner(cfg, f, s, log, opts),
log: log,
guard: Guard{
MaxDeactivate: cfg.Guard.MaxDeactivate,
MaxDeactivatePercent: cfg.Guard.MaxDeactivatePercent,
},
}
}
// RunSnapshot 触发一轮快照(飞书 → 镜像 + 离职流程推进)。
func (r *Runner) RunSnapshot(ctx context.Context) (*SnapshotRunResult, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.snapshot.RunSnapshot(ctx)
}
// Preview 只计算物化计划,不做任何写入。dry-run 与真正执行共用同一份计划代码。
func (r *Runner) Preview(ctx context.Context) (*Plan, error) {
plan, _, err := r.plan(ctx)
return plan, err
}
// Run 执行一轮物化(镜像 → LLDAP)。dryRun 为 true 时只计算并返回计划。
func (r *Runner) Run(ctx context.Context, dryRun bool, force bool, trigger string) (*Plan, *Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
run, err := r.store.StartRun(modeOf(dryRun), trigger)
if err != nil {
return nil, nil, fmt.Errorf("创建物化记录失败: %w", err)
}
plan, desired, err := r.prepare(ctx, run, force)
if err != nil {
return plan, nil, err
}
if dryRun {
fillRunFromPlan(run, plan)
run.Status = "success"
if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入物化记录失败", "err", err)
}
return plan, nil, nil
}
res := r.apply(ctx, plan)
fillRunFromResult(run, plan, res)
// 只有全部成功才回写镜像:部分失败时保持镜像不变,下轮重算差异。
if res.Failed == 0 {
if err := r.persist(ctx, plan, desired); err != nil {
run.Status = "failed"
run.ErrorMsg = "回写镜像失败: " + err.Error()
_ = r.store.FinishRun(run)
return plan, res, fmt.Errorf("回写镜像失败: %w", err)
}
} else {
r.log.Warn("存在失败的操作,跳过镜像回写", "failed", res.Failed)
}
run.Status = "success"
if res.Failed > 0 {
run.Status = "failed"
run.ErrorMsg = strings.Join(res.Errors, "; ")
}
if err := r.store.FinishRun(run); err != nil {
r.log.Warn("写入物化记录失败", "err", err)
}
return plan, res, nil
}
// prepare 计算计划并做安全检查。
func (r *Runner) prepare(ctx context.Context, run *store.SyncRun, force bool) (*Plan, *mapping.DesiredState, error) {
plan, desired, err := r.plan(ctx)
if err != nil {
run.Status = "failed"
run.ErrorMsg = err.Error()
_ = r.store.FinishRun(run)
return nil, nil, err
}
guard := r.guard
if force {
guard.AllowBulk = true
}
if err := guard.Check(plan); err != nil {
run.Status = "failed"
run.ErrorMsg = err.Error()
_ = r.store.FinishRun(run)
return plan, desired, err
}
return plan, desired, nil
}
// plan 读两侧状态并计算物化计划。
func (r *Runner) plan(ctx context.Context) (*Plan, *mapping.DesiredState, error) {
// 期望侧:只读 SQLite 镜像的 active 视图,不碰飞书。
activeUsers, err := r.store.ActiveUsersWithDepts()
if err != nil {
return nil, nil, err
}
depts, err := r.store.ActiveDepartments()
if err != nil {
return nil, nil, err
}
desired := mapping.BuildFromMirror(activeUsers, depts, r.cfg.Ldap.BaseDN)
// 实际侧:读 LLDAP 的 LDAP 视图。
conn, err := r.ldap.Dial()
if err != nil {
return nil, nil, err
}
defer conn.Close()
actualUsers, err := conn.ListUsers(r.cfg.UserOUDN())
if err != nil {
return nil, nil, err
}
actualGroups, err := conn.ListGroups(r.cfg.GroupRootDN())
if err != nil {
return nil, nil, err
}
// 所有权与删除白名单都来自镜像台账。
all, err := r.store.AllMirrorUsers()
if err != nil {
return nil, nil, err
}
owned := make(map[string]struct{}, len(all))
confirmed := make(map[string]bool)
for _, u := range all {
if u.Skipped {
continue
}
owned[u.UID] = struct{}{}
if u.State == store.StateConfirmedLeave {
confirmed[u.UID] = true
}
}
plan := ComputePlan(&Input{
Desired: desired,
ActualUsers: actualUsers,
ActualGroups: actualGroups,
OwnedUsers: owned,
ConfirmedLeave: confirmed,
AdminDN: r.cfg.Ldap.AdminDN,
PeopleOU: r.cfg.UserOUDN(),
GroupRootDN: r.cfg.GroupRootDN(),
})
return plan, desired, nil
}
// apply 通过 GraphQL 执行计划。每次物化开始时登录一次,并拉全量组建立 dept_id→groupId 映射。
func (r *Runner) apply(ctx context.Context, plan *Plan) *Result {
if err := r.gql.Login(ctx, r.cfg.Lldap.AdminUser, r.cfg.Lldap.AdminPass); err != nil {
return &Result{Failed: 1, Errors: []string{err.Error()}}
}
groups, err := r.gql.Groups(ctx)
if err != nil {
return &Result{Failed: 1, Errors: []string{err.Error()}}
}
groupIDs := make(map[string]int, len(groups))
for _, g := range groups {
groupIDs[g.DisplayName] = g.ID
}
exec := NewExecutor(r.gql, ExecConfig{PeopleOU: r.cfg.UserOUDN()}, r.log)
res := exec.Apply(ctx, plan, groupIDs)
r.groupIDs = groupIDs
return res
}
// persist 在物化全部成功后回写镜像:用户 attrs_hash、部门 groupId、被删用户标记 deleted。
func (r *Runner) persist(ctx context.Context, plan *Plan, desired *mapping.DesiredState) error {
for _, u := range desired.Users {
if err := r.store.SetUserAttrsHash(u.UID, AttrsHash(u)); err != nil {
return err
}
}
for deptID, gid := range r.groupIDs {
if err := r.store.SetDeptGroupID(deptID, gid); err != nil {
return err
}
}
for _, op := range plan.Ops {
if op.Kind == OpDeleteUser {
if err := r.store.MarkDeleted(op.Target); err != nil {
return err
}
}
}
return nil
}
// modeOf 把 dry-run 标志转成记录用的模式名。
func modeOf(dryRun bool) string {
if dryRun {
return "dry-run"
}
return "apply"
}
// fillRunFromPlan 用计划统计填充物化记录(dry-run 时使用)。
func fillRunFromPlan(run *store.SyncRun, plan *Plan) {
run.Created = plan.Stats.CreateUser + plan.Stats.CreateGroup
run.Updated = plan.Stats.UpdateUser
run.Added = plan.Stats.AddMember
run.Removed = plan.Stats.RemoveMember
run.Skipped = plan.Stats.Unchanged
}
// fillRunFromResult 用实际执行结果填充物化记录。
func fillRunFromResult(run *store.SyncRun, plan *Plan, res *Result) {
run.Created = res.Created
run.Updated = res.Updated
run.Added = res.Added
run.Removed = res.Removed
run.Failed = res.Failed
run.Skipped = plan.Stats.Unchanged
}