Files
idsync/internal/syncer/runner.go
T
caowangrenbo 126e52a6ed feat: syncer/exec 物化(GraphQL 落库)+ 两阶段编排 main/server
- plan.go:新 diff 引擎(纯函数);输入=镜像期望状态 BuildFromMirror + LLDAP 实际
  LDAP 视图;操作顺序 分组→用户→加组→移组→删除,删除仅 confirmed_leave 且最后阶段;
  所有权只认镜像台账,服务账号永不碰;孤儿组只告警不自动删
- exec.go:写走 lldapgql;deleteUser 级联清成员;groupId 用 dept_id→groupId 映射
  (物化开始时经 GraphQL Groups 拉取),不凭名字反推
- runner.go:两阶段编排,快照与物化共享单实例互斥锁;物化开始登录一次、
  全成功才回写 attrs_hash/groupId/MarkDeleted
- main.go:-snapshot/-apply/-dry-run 命令行 + 两套定时任务(快照低频/物化略高频)+ check-config
- server.go:镜像视图用户/部门、待确认列表、快照触发、一次性离职确认端点
  (不经 Auth,token 即凭证)、skipped 展示
- 安全结构拦截均已配变异验证用例(plan_test):未确认不删、非受管不删、
  admin 永不移出、删除最后阶段
2026-09-29 23:48:55 +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.ActiveUsers()
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
}