fix(keystone): project sync org by metadata (#25792)

This commit is contained in:
屈轩
2026-10-08 11:17:23 +08:00
committed by GitHub
parent 088b3923a0
commit 8b61535eea
4 changed files with 262 additions and 15 deletions
+2 -2
View File
@@ -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")
}
+166
View File
@@ -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{
+17 -13
View File
@@ -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")
+77
View File
@@ -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