From 8b61535eea249c588112eb2fb94f3d1d11c255bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B1=88=E8=BD=A9?= Date: Thu, 8 Oct 2026 11:17:23 +0800 Subject: [PATCH] fix(keystone): project sync org by metadata (#25792) --- pkg/cloudcommon/db/standalone_anon.go | 4 +- pkg/keystone/models/organizations.go | 166 ++++++++++++++++++++++++++ pkg/keystone/models/projects.go | 30 +++-- pkg/keystone/models/projects_test.go | 77 ++++++++++++ 4 files changed, 262 insertions(+), 15 deletions(-) diff --git a/pkg/cloudcommon/db/standalone_anon.go b/pkg/cloudcommon/db/standalone_anon.go index 097979c41f..fb03fae297 100644 --- a/pkg/cloudcommon/db/standalone_anon.go +++ b/pkg/cloudcommon/db/standalone_anon.go @@ -707,7 +707,7 @@ func (model *SStandaloneAnonResourceBase) PerformMetadata(ctx context.Context, u // 更新资源的用户标签 // +onecloud:swagger-gen-ignore func (model *SStandaloneAnonResourceBase) PerformUserMetadata(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformUserMetadataInput) (jsonutils.JSONObject, error) { - err := model.SetUserMetadataValues(ctx, input, userCred) + err := model.GetIStandaloneModel().SetUserMetadataValues(ctx, input, userCred) if err != nil { return nil, errors.Wrap(err, "SetUserMetadataValues") } @@ -716,7 +716,7 @@ func (model *SStandaloneAnonResourceBase) PerformUserMetadata(ctx context.Contex // 全量替换资源的所有用户标签 func (model *SStandaloneAnonResourceBase) PerformSetUserMetadata(ctx context.Context, userCred mcclient.TokenCredential, query jsonutils.JSONObject, input apis.PerformSetUserMetadataInput) (jsonutils.JSONObject, error) { - err := model.SetUserMetadataAll(ctx, input, userCred) + err := model.GetIStandaloneModel().SetUserMetadataAll(ctx, input, userCred) if err != nil { return nil, errors.Wrap(err, "SetUserMetadataAll") } diff --git a/pkg/keystone/models/organizations.go b/pkg/keystone/models/organizations.go index 891ea1507b..4b0fac096b 100644 --- a/pkg/keystone/models/organizations.go +++ b/pkg/keystone/models/organizations.go @@ -627,6 +627,172 @@ func (org *SOrganization) PerformDisable( return nil, nil } +type organizationMetadataTarget interface { + GetId() string + GetAllUserMetadata() (map[string]string, error) + GetAllOrganizationMetadata() (map[string]string, error) + SetOrganizationMetadataAll(ctx context.Context, meta map[string]string, userCred mcclient.TokenCredential) error +} + +func isActiveOrganizationTagValue(val string) bool { + if len(strings.TrimSpace(val)) == 0 { + return false + } + switch strings.ToLower(val) { + case "none", "null": + return false + } + return val != tagutils.NoValue +} + +// rewriteInactiveOrganizationUserTags keeps organization keys that the caller +// cleared with none/null as empty values. Metadata.SetAll would otherwise delete +// those keys, so OnMetadataUpdated could not tell an explicit clear from an +// unrelated tag edit that simply omitted the top organization key. +func rewriteInactiveOrganizationUserTags(orgKeys []string, dictstore map[string]string) map[string]string { + if len(dictstore) == 0 || len(orgKeys) == 0 { + return dictstore + } + orgKeySet := make(map[string]struct{}, len(orgKeys)) + for _, key := range orgKeys { + orgKeySet[key] = struct{}{} + } + out := make(map[string]string, len(dictstore)) + changed := false + for key, val := range dictstore { + bare := key + if strings.HasPrefix(bare, db.USER_TAG_PREFIX) { + bare = bare[len(db.USER_TAG_PREFIX):] + } + if _, ok := orgKeySet[bare]; ok && !isActiveOrganizationTagValue(val) { + out[key] = "" + if val != "" { + changed = true + } + continue + } + out[key] = val + } + if !changed { + return dictstore + } + return out +} + +func preserveInactiveOrganizationUserTags(orgType api.TOrgType, dictstore map[string]string) map[string]string { + if len(dictstore) == 0 { + return dictstore + } + org, err := fetchEnabledOrganization(orgType) + if err != nil || org == nil { + return dictstore + } + return rewriteInactiveOrganizationUserTags(org.GetKeys(), dictstore) +} + +// organizationTagsFromUserTags builds the node path from user tags. +// touched is true only when the top organization key is present, so a project +// bound solely by organization tags is not detached by unrelated tag edits. +// A present but empty top key clears the path and unbinds the project. +func organizationTagsFromUserTags(keys []string, userTags map[string]string) (labels []string, orgTags map[string]string, touched bool) { + orgTags = make(map[string]string) + if len(keys) == 0 { + return nil, orgTags, false + } + if _, ok := userTags[keys[0]]; !ok { + return nil, orgTags, false + } + for _, key := range keys { + val, ok := userTags[key] + if !ok || !isActiveOrganizationTagValue(val) { + break + } + labels = append(labels, val) + orgTags[key] = val + } + return labels, orgTags, true +} + +func sameStringMap(a, b map[string]string) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if bv, ok := b[k]; !ok || bv != v { + return false + } + } + return true +} + +func (org *SOrganization) ensureNodePath(ctx context.Context, labels []string) error { + for i := range labels { + fullLabel := api.JoinLabels(labels[:i+1]...) + _, err := org.getNode(fullLabel) + if err == nil { + continue + } + if errors.Cause(err) != sql.ErrNoRows { + return errors.Wrapf(err, "getNode %s", fullLabel) + } + if _, err := OrganizationNodeManager.ensureNode(ctx, org.Id, fullLabel, nil, ""); err != nil { + return errors.Wrapf(err, "ensureNode %s", fullLabel) + } + } + return nil +} + +func fetchEnabledOrganization(orgType api.TOrgType) (*SOrganization, error) { + orgs, err := OrganizationManager.FetchOrgnaizations(func(q *sqlchemy.SQuery) *sqlchemy.SQuery { + q = q.Equals("type", orgType) + q = q.IsTrue("enabled") + return q + }) + if err != nil { + return nil, errors.Wrap(err, "FetchOrgnaizations") + } + if len(orgs) == 0 { + return nil, nil + } + if len(orgs) > 1 { + return nil, errors.Wrap(httperrors.ErrDuplicateResource, "multiple enabled organizations") + } + return &orgs[0], nil +} + +// syncEnabledOrganizationByUserTags moves the resource onto the organization +// node that matches its user tags when an organization of orgType is enabled. +func syncEnabledOrganizationByUserTags(ctx context.Context, userCred mcclient.TokenCredential, orgType api.TOrgType, obj organizationMetadataTarget) error { + org, err := fetchEnabledOrganization(orgType) + if err != nil || org == nil { + return err + } + keys := org.GetKeys() + if len(keys) == 0 { + return nil + } + userTags, err := obj.GetAllUserMetadata() + if err != nil { + return errors.Wrap(err, "GetAllUserMetadata") + } + labels, orgTags, touched := organizationTagsFromUserTags(keys, userTags) + if !touched { + return nil + } + if err := org.ensureNodePath(ctx, labels); err != nil { + return errors.Wrap(err, "ensureNodePath") + } + current, err := obj.GetAllOrganizationMetadata() + if err != nil { + return errors.Wrap(err, "GetAllOrganizationMetadata") + } + if sameStringMap(current, orgTags) { + return nil + } + log.Infof("switch %s %s to organization %s node %s", orgType, obj.GetId(), org.Id, api.JoinLabels(labels...)) + return obj.SetOrganizationMetadataAll(ctx, orgTags, userCred) +} + func (org *SOrganization) getProjectOrganization(tags map[string]string) (*api.SProjectOrganization, error) { keys := api.SplitLabel(org.Keys) ret := api.SProjectOrganization{ diff --git a/pkg/keystone/models/projects.go b/pkg/keystone/models/projects.go index b2b63e7f08..63ad36db00 100644 --- a/pkg/keystone/models/projects.go +++ b/pkg/keystone/models/projects.go @@ -919,21 +919,25 @@ func (project *SProject) setAdminId(ctx context.Context, userCred mcclient.Token return nil } +func (project *SProject) SetUserMetadataValues(ctx context.Context, dictstore map[string]string, userCred mcclient.TokenCredential) error { + return project.SStandaloneAnonResourceBase.SetUserMetadataValues(ctx, preserveInactiveOrganizationUserTags(api.OrgTypeProject, dictstore), userCred) +} + +func (project *SProject) SetUserMetadataAll(ctx context.Context, dictstore map[string]string, userCred mcclient.TokenCredential) error { + return project.SStandaloneAnonResourceBase.SetUserMetadataAll(ctx, preserveInactiveOrganizationUserTags(api.OrgTypeProject, dictstore), userCred) +} + +func (project *SProject) OnMetadataUpdated(ctx context.Context, userCred mcclient.TokenCredential) { + if err := syncEnabledOrganizationByUserTags(ctx, userCred, api.OrgTypeProject, project); err != nil { + log.Errorf("project %s sync organization node by tags: %s", project.Id, err) + } +} + func (project *SProject) matchOrganizationNodes() (*api.SProjectOrganization, error) { - orgs, err := OrganizationManager.FetchOrgnaizations(func(q *sqlchemy.SQuery) *sqlchemy.SQuery { - q = q.Equals("type", api.OrgTypeProject) - q = q.IsTrue("enabled") - return q - }) - if err != nil { - return nil, errors.Wrap(err, "FetchOrgnaizations") + org, err := fetchEnabledOrganization(api.OrgTypeProject) + if err != nil || org == nil { + return nil, err } - if len(orgs) == 0 { - return nil, nil - } else if len(orgs) > 1 { - return nil, errors.Wrap(httperrors.ErrDuplicateResource, "multiple enabled organizations") - } - org := &orgs[0] tags, err := project.GetAllOrganizationMetadata() if err != nil { return nil, errors.Wrap(err, "GetAllOrganizationMetadata") diff --git a/pkg/keystone/models/projects_test.go b/pkg/keystone/models/projects_test.go index eb7bb3bad3..c8ace07242 100644 --- a/pkg/keystone/models/projects_test.go +++ b/pkg/keystone/models/projects_test.go @@ -15,9 +15,86 @@ package models import ( + "strings" "testing" ) +func TestOrganizationTagsFromUserTags(t *testing.T) { + keys := []string{"部门", "小组"} + labels, orgTags, touched := organizationTagsFromUserTags(keys, map[string]string{ + "部门": "销售", + "小组": "华北", + "负责人": "alice", + }) + if !touched { + t.Fatalf("touched = false, want true") + } + if got, want := apiJoin(labels), "销售/华北"; got != want { + t.Fatalf("labels = %s, want %s", got, want) + } + if !sameStringMap(orgTags, map[string]string{"部门": "销售", "小组": "华北"}) { + t.Fatalf("orgTags = %#v", orgTags) + } + + labels, orgTags, touched = organizationTagsFromUserTags(keys, map[string]string{ + "部门": "研发", + "小组": "none", + }) + if !touched || apiJoin(labels) != "研发" || len(orgTags) != 1 || orgTags["部门"] != "研发" { + t.Fatalf("stop at cleared child: labels=%v orgTags=%v touched=%v", labels, orgTags, touched) + } + + _, _, touched = organizationTagsFromUserTags(keys, map[string]string{"负责人": "bob"}) + if touched { + t.Fatalf("unrelated tags should not touch organization binding") + } + + _, _, touched = organizationTagsFromUserTags(keys, map[string]string{"小组": "后端"}) + if touched { + t.Fatalf("missing top key should keep the current binding") + } + + labels, orgTags, touched = organizationTagsFromUserTags(keys, map[string]string{"部门": ""}) + if !touched || len(labels) != 0 || len(orgTags) != 0 { + t.Fatalf("empty top key should clear path: labels=%v orgTags=%v touched=%v", labels, orgTags, touched) + } + + labels, orgTags, touched = organizationTagsFromUserTags(keys, map[string]string{"部门": "none"}) + if !touched || len(labels) != 0 || len(orgTags) != 0 { + t.Fatalf("none top key should clear path: labels=%v orgTags=%v touched=%v", labels, orgTags, touched) + } +} + +func TestRewriteInactiveOrganizationUserTags(t *testing.T) { + keys := []string{"部门", "小组"} + out := rewriteInactiveOrganizationUserTags(keys, map[string]string{ + "部门": "none", + "小组": "null", + "foo": "bar", + }) + if out["部门"] != "" || out["小组"] != "" || out["foo"] != "bar" { + t.Fatalf("inactive org keys should become empty: %#v", out) + } + + out = rewriteInactiveOrganizationUserTags(keys, map[string]string{ + "user:部门": "none", + "foo": "bar", + }) + if out["user:部门"] != "" || out["foo"] != "bar" { + t.Fatalf("prefixed inactive org key should become empty: %#v", out) + } + + in := map[string]string{"部门": "销售", "foo": "bar"} + out = rewriteInactiveOrganizationUserTags(keys, in) + if out["部门"] != "销售" || out["foo"] != "bar" { + t.Fatalf("active tags should stay unchanged: %#v", out) + } +} + +func apiJoin(labels []string) string { + return strings.Join(labels, "/") +} + func TestNormalizeProjectName(t *testing.T) { cases := []struct { In string