From 8fa653497c1e98562b45b85da21fa67757578328 Mon Sep 17 00:00:00 2001 From: Zichen Yu <1062955096@qq.com> Date: Thu, 30 Apr 2026 10:40:08 +0800 Subject: [PATCH 1/2] feat: support random suffix --- README.md | 2 +- pkg/config/name_resolver.go | 41 +++++++++++++-- pkg/config/name_resolver_test.go | 86 +++++++++++++++++++++++++++++++- pkg/config/types.go | 2 +- 4 files changed, 124 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 32b31ea..f019fb6 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Refer to `config/example.yaml` for more configuration options. `nameMode` controls how resource identifiers are generated before resources are created: - `name`: keep configured values unchanged (default behavior) -- `suffix`: append an auto-generated timestamp suffix (format: `YYYYMMDDHHMMSS`) +- `suffix`: append an auto-generated unique suffix (format: `YYYYMMDDHHMMSSmmm-rand4`) You can set `nameMode` globally and override it per resource: diff --git a/pkg/config/name_resolver.go b/pkg/config/name_resolver.go index 6301b9e..b4fc41b 100644 --- a/pkg/config/name_resolver.go +++ b/pkg/config/name_resolver.go @@ -2,6 +2,7 @@ package config import ( + "crypto/rand" "fmt" "strings" "time" @@ -10,8 +11,17 @@ import ( const ( // NameModeName keeps the configured resource names unchanged. NameModeName = "name" - // NameModeSuffix appends a generated timestamp suffix to configured resource names. + // NameModeSuffix appends a generated unique suffix to configured resource names. NameModeSuffix = "suffix" + // defaultRandomSuffixLength controls the random tail length for generated suffixes. + defaultRandomSuffixLength = 4 +) + +var ( + // shortSuffixAlphabet contains safe lowercase alphanumeric characters for generated suffixes. + shortSuffixAlphabet = []byte("abcdefghijklmnopqrstuvwxyz0123456789") + // randomRead allows tests to exercise fallback branches without mutating crypto/rand internals. + randomRead = rand.Read ) // ResolveNames resolves nameMode for all supported resources and rewrites references. @@ -39,9 +49,11 @@ func (c *Config) ResolveNamesWithSuffix(suffix string) (*Config, error) { return resolved, nil } -// GenerateTimestampSuffix returns timestamp in the same format used by gitlab-cli. +// GenerateTimestampSuffix returns a unique suffix in yyyyMMddHHmmssSSS-rand4 format. func GenerateTimestampSuffix() string { - return time.Now().Format("20060102150405") + now := time.Now() + millisecond := now.Nanosecond() / int(time.Millisecond) + return fmt.Sprintf("%s%03d-%s", now.Format("20060102150405"), millisecond, generateShortRandomSuffix(defaultRandomSuffixLength)) } // resolveNamesWithSuffix performs a full nameMode resolution and returns whether suffix mode was applied. @@ -204,6 +216,29 @@ func emailWithSuffix(email string, suffix string) string { return fmt.Sprintf("%s+%s%s", email[:at], suffix, email[at:]) } +// generateShortRandomSuffix returns a short lowercase alphanumeric suffix. +func generateShortRandomSuffix(length int) string { + if length <= 0 { + length = defaultRandomSuffixLength + } + + rawRandomBytes := make([]byte, length) + if _, err := randomRead(rawRandomBytes); err != nil { + fallbackFromTime := fmt.Sprintf("%d", time.Now().UnixNano()) + if len(fallbackFromTime) > length { + return fallbackFromTime[len(fallbackFromTime)-length:] + } + return fallbackFromTime + } + + randomSuffix := make([]byte, length) + for i := range rawRandomBytes { + randomSuffix[i] = shortSuffixAlphabet[int(rawRandomBytes[i])%len(shortSuffixAlphabet)] + } + + return string(randomSuffix) +} + // remapStrings remaps values using map and keeps unknown values unchanged. func remapStrings(values []string, mapping map[string]string) []string { if len(values) == 0 { diff --git a/pkg/config/name_resolver_test.go b/pkg/config/name_resolver_test.go index 524275b..2ad7851 100644 --- a/pkg/config/name_resolver_test.go +++ b/pkg/config/name_resolver_test.go @@ -1,6 +1,7 @@ package config import ( + "io" "reflect" "regexp" "strings" @@ -247,18 +248,99 @@ func TestResolveNamesGenerateSuffix(t *testing.T) { t.Fatal("ResolveNames() suffix should not be empty in suffix mode") } - matched, err := regexp.MatchString(`^[0-9]{14}$`, suffix) + matched, err := regexp.MatchString(`^[0-9]{17}-[a-z0-9]{4}$`, suffix) if err != nil { t.Fatalf("regexp.MatchString() error = %v", err) } if !matched { - t.Fatalf("ResolveNames() suffix format = %q, want 14-digit timestamp", suffix) + t.Fatalf("ResolveNames() suffix format = %q, want millisecond timestamp plus random tail", suffix) } if !strings.HasSuffix(resolved.Users[0].ID, "-"+suffix) { t.Fatalf("resolved user id = %q should end with -%s", resolved.Users[0].ID, suffix) } } +// TestResolveNamesWithoutSuffixModeReturnsEmptyGeneratedSuffix verifies non-suffix mode does not expose a generated suffix. +func TestResolveNamesWithoutSuffixModeReturnsEmptyGeneratedSuffix(t *testing.T) { + cfg := &Config{ + Users: []User{ + {ID: "repo-admin"}, + }, + } + + resolved, suffix, err := cfg.ResolveNames() + if err != nil { + t.Fatalf("ResolveNames() error = %v", err) + } + if suffix != "" { + t.Fatalf("ResolveNames() suffix = %q, want empty", suffix) + } + if got, want := resolved.Users[0].ID, "repo-admin"; got != want { + t.Fatalf("resolved user id = %q, want %q", got, want) + } +} + +// TestWithSuffixKeepsOriginalValueWhenSuffixingIsNotPossible verifies empty inputs do not change the original value. +func TestWithSuffixKeepsOriginalValueWhenSuffixingIsNotPossible(t *testing.T) { + if got, want := withSuffix("", "20260324112233"), ""; got != want { + t.Fatalf("withSuffix() empty value = %q, want %q", got, want) + } + if got, want := withSuffix("repo-admin", ""), "repo-admin"; got != want { + t.Fatalf("withSuffix() empty suffix = %q, want %q", got, want) + } +} + +// TestGenerateShortRandomSuffixUsesDefaultLength verifies non-positive lengths fall back to the default suffix length. +func TestGenerateShortRandomSuffixUsesDefaultLength(t *testing.T) { + originalRandomRead := randomRead + randomRead = func(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil + } + defer func() { + randomRead = originalRandomRead + }() + + got := generateShortRandomSuffix(0) + if got != "aaaa" { + t.Fatalf("generateShortRandomSuffix() = %q, want %q", got, "aaaa") + } +} + +// TestGenerateShortRandomSuffixFallsBackToTime verifies random-reader failures still return numeric suffix material. +func TestGenerateShortRandomSuffixFallsBackToTime(t *testing.T) { + originalRandomRead := randomRead + randomRead = func(_ []byte) (int, error) { + return 0, io.ErrClosedPipe + } + defer func() { + randomRead = originalRandomRead + }() + + gotTrimmed := generateShortRandomSuffix(4) + matchedTrimmed, err := regexp.MatchString(`^[0-9]{4}$`, gotTrimmed) + if err != nil { + t.Fatalf("regexp.MatchString() error = %v", err) + } + if !matchedTrimmed { + t.Fatalf("generateShortRandomSuffix() trimmed fallback = %q, want 4 digits", gotTrimmed) + } + + gotFull := generateShortRandomSuffix(64) + matchedFull, err := regexp.MatchString(`^[0-9]+$`, gotFull) + if err != nil { + t.Fatalf("regexp.MatchString() error = %v", err) + } + if !matchedFull { + t.Fatalf("generateShortRandomSuffix() full fallback = %q, want digits only", gotFull) + } + if len(gotFull) >= 64 { + t.Fatalf("generateShortRandomSuffix() full fallback length = %d, want less than 64", len(gotFull)) + } +} + // TestCloneConfigDeepCopyMutableNestedFields ensures cloneConfig does not share mutable nested state. func TestCloneConfigDeepCopyMutableNestedFields(t *testing.T) { cfg := &Config{ diff --git a/pkg/config/types.go b/pkg/config/types.go index 2631571..8b3eec9 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -4,7 +4,7 @@ package config // Config 主配置结构 type Config struct { // NameMode controls default naming behavior for all resources. - // "name" keeps names unchanged; "suffix" appends a generated timestamp suffix. + // "name" keeps names unchanged; "suffix" appends a generated unique suffix. NameMode string `yaml:"nameMode,omitempty"` Users []User `yaml:"users"` Repositories []Repository `yaml:"repositories"` From 219c1cdb54ff02583b0272e909fc40c25cf73d14 Mon Sep 17 00:00:00 2001 From: Zichen Yu <1062955096@qq.com> Date: Thu, 30 Apr 2026 15:37:40 +0800 Subject: [PATCH 2/2] feat: support random suffix --- pkg/config/name_resolver.go | 176 +++++++++++++++++++++++------------- 1 file changed, 112 insertions(+), 64 deletions(-) diff --git a/pkg/config/name_resolver.go b/pkg/config/name_resolver.go index b4fc41b..b8719d2 100644 --- a/pkg/config/name_resolver.go +++ b/pkg/config/name_resolver.go @@ -66,120 +66,168 @@ func (c *Config) resolveNamesWithSuffix(suffix string) (*Config, bool, error) { if err != nil { return nil, false, fmt.Errorf("invalid config.nameMode: %w", err) } - // Prevalidate suffix once to avoid repeated checks in every resource loop. - if defaultMode == NameModeSuffix && suffix == "" { - return nil, false, fmt.Errorf("nameMode suffix requires a non-empty suffix") - } resolved := cloneConfig(c) - appliedSuffix := false + state := newNameResolutionState(resolved) + + if err := resolveUsers(resolved.Users, defaultMode, suffix, state); err != nil { + return nil, false, err + } + if err := resolveRepositories(resolved.Repositories, defaultMode, suffix, state); err != nil { + return nil, false, err + } + if err := resolvePrivileges(resolved.Privileges, defaultMode, suffix, state); err != nil { + return nil, false, err + } + if err := resolveRoles(resolved.Roles, defaultMode, suffix, state); err != nil { + return nil, false, err + } + + rewriteResolvedReferences(resolved, state) - userIDMap := make(map[string]string, len(resolved.Users)) - repositoryNameMap := make(map[string]string, len(resolved.Repositories)) - roleIDMap := make(map[string]string, len(resolved.Roles)) - privilegeNameMap := make(map[string]string, len(resolved.Privileges)) + clearNameModes(resolved) + + return resolved, state.appliedSuffix, nil +} + +// nameResolutionState tracks rewritten identifiers and whether suffix mode was used. +type nameResolutionState struct { + appliedSuffix bool + userIDMap map[string]string + repositoryNameMap map[string]string + roleIDMap map[string]string + privilegeNameMap map[string]string +} + +// newNameResolutionState allocates lookup maps sized for the target config. +func newNameResolutionState(cfg *Config) *nameResolutionState { + return &nameResolutionState{ + userIDMap: make(map[string]string, len(cfg.Users)), + repositoryNameMap: make(map[string]string, len(cfg.Repositories)), + roleIDMap: make(map[string]string, len(cfg.Roles)), + privilegeNameMap: make(map[string]string, len(cfg.Privileges)), + } +} + +// resolveResourceNameMode validates a resource-level nameMode and enforces suffix requirements. +func resolveResourceNameMode(mode string, fallback string, fieldPath string, suffix string) (string, error) { + resolvedMode, err := normalizeNameMode(mode, fallback) + if err != nil { + return "", fmt.Errorf("invalid %s.nameMode: %w", fieldPath, err) + } + if resolvedMode == NameModeSuffix && suffix == "" { + return "", fmt.Errorf("nameMode suffix requires a non-empty suffix") + } + return resolvedMode, nil +} - for i, user := range resolved.Users { - mode, err := normalizeNameMode(user.NameMode, defaultMode) +// resolveUsers rewrites user identifiers and records the old-to-new mapping. +func resolveUsers(users []User, defaultMode string, suffix string, state *nameResolutionState) error { + for i, user := range users { + mode, err := resolveResourceNameMode(user.NameMode, defaultMode, fmt.Sprintf("users[%d]", i), suffix) if err != nil { - return nil, false, fmt.Errorf("invalid users[%d].nameMode: %w", i, err) - } - if mode == NameModeSuffix && suffix == "" { - return nil, false, fmt.Errorf("nameMode suffix requires a non-empty suffix") + return err } actualID := user.ID if mode == NameModeSuffix { - appliedSuffix = true + state.appliedSuffix = true actualID = withSuffix(user.ID, suffix) - resolved.Users[i].ID = actualID - resolved.Users[i].EmailAddress = emailWithSuffix(user.EmailAddress, suffix) + users[i].ID = actualID + users[i].EmailAddress = emailWithSuffix(user.EmailAddress, suffix) } - userIDMap[user.ID] = actualID + state.userIDMap[user.ID] = actualID } - for i, repository := range resolved.Repositories { - mode, err := normalizeNameMode(repository.NameMode, defaultMode) + return nil +} + +// resolveRepositories rewrites repository names and records the old-to-new mapping. +func resolveRepositories(repositories []Repository, defaultMode string, suffix string, state *nameResolutionState) error { + for i, repository := range repositories { + mode, err := resolveResourceNameMode(repository.NameMode, defaultMode, fmt.Sprintf("repositories[%d]", i), suffix) if err != nil { - return nil, false, fmt.Errorf("invalid repositories[%d].nameMode: %w", i, err) - } - if mode == NameModeSuffix && suffix == "" { - return nil, false, fmt.Errorf("nameMode suffix requires a non-empty suffix") + return err } actualName := repository.Name if mode == NameModeSuffix { - appliedSuffix = true + state.appliedSuffix = true actualName = withSuffix(repository.Name, suffix) - resolved.Repositories[i].Name = actualName + repositories[i].Name = actualName } - repositoryNameMap[repository.Name] = actualName + state.repositoryNameMap[repository.Name] = actualName } - for i, privilege := range resolved.Privileges { - mode, err := normalizeNameMode(privilege.NameMode, defaultMode) + return nil +} + +// resolvePrivileges rewrites privilege names and records the old-to-new mapping. +func resolvePrivileges(privileges []Privilege, defaultMode string, suffix string, state *nameResolutionState) error { + for i, privilege := range privileges { + mode, err := resolveResourceNameMode(privilege.NameMode, defaultMode, fmt.Sprintf("privileges[%d]", i), suffix) if err != nil { - return nil, false, fmt.Errorf("invalid privileges[%d].nameMode: %w", i, err) - } - if mode == NameModeSuffix && suffix == "" { - return nil, false, fmt.Errorf("nameMode suffix requires a non-empty suffix") + return err } actualName := privilege.Name if mode == NameModeSuffix { - appliedSuffix = true + state.appliedSuffix = true actualName = withSuffix(privilege.Name, suffix) - resolved.Privileges[i].Name = actualName + privileges[i].Name = actualName } - privilegeNameMap[privilege.Name] = actualName + state.privilegeNameMap[privilege.Name] = actualName } - for i, role := range resolved.Roles { - mode, err := normalizeNameMode(role.NameMode, defaultMode) + return nil +} + +// resolveRoles rewrites role identifiers and records the old-to-new mapping. +func resolveRoles(roles []Role, defaultMode string, suffix string, state *nameResolutionState) error { + for i, role := range roles { + mode, err := resolveResourceNameMode(role.NameMode, defaultMode, fmt.Sprintf("roles[%d]", i), suffix) if err != nil { - return nil, false, fmt.Errorf("invalid roles[%d].nameMode: %w", i, err) - } - if mode == NameModeSuffix && suffix == "" { - return nil, false, fmt.Errorf("nameMode suffix requires a non-empty suffix") + return err } actualID := role.ID if mode == NameModeSuffix { - appliedSuffix = true + state.appliedSuffix = true actualID = withSuffix(role.ID, suffix) - resolved.Roles[i].ID = actualID - resolved.Roles[i].Name = withSuffix(role.Name, suffix) + roles[i].ID = actualID + roles[i].Name = withSuffix(role.Name, suffix) } - roleIDMap[role.ID] = actualID + state.roleIDMap[role.ID] = actualID } - for i := range resolved.Users { - resolved.Users[i].Roles = remapStrings(resolved.Users[i].Roles, roleIDMap) + return nil +} + +// rewriteResolvedReferences updates cross-resource references after primary names are rewritten. +func rewriteResolvedReferences(cfg *Config, state *nameResolutionState) { + for i := range cfg.Users { + cfg.Users[i].Roles = remapStrings(cfg.Users[i].Roles, state.roleIDMap) } - for i := range resolved.Roles { - resolved.Roles[i].Privileges = remapStrings(resolved.Roles[i].Privileges, privilegeNameMap) - resolved.Roles[i].Roles = remapStrings(resolved.Roles[i].Roles, roleIDMap) + for i := range cfg.Roles { + cfg.Roles[i].Privileges = remapStrings(cfg.Roles[i].Privileges, state.privilegeNameMap) + cfg.Roles[i].Roles = remapStrings(cfg.Roles[i].Roles, state.roleIDMap) } - for i := range resolved.Privileges { - if mappedRepo, ok := repositoryNameMap[resolved.Privileges[i].Repository]; ok { - resolved.Privileges[i].Repository = mappedRepo + for i := range cfg.Privileges { + if mappedRepo, ok := state.repositoryNameMap[cfg.Privileges[i].Repository]; ok { + cfg.Privileges[i].Repository = mappedRepo } } - for i := range resolved.UserRepositoryPermissions { - if mappedUser, ok := userIDMap[resolved.UserRepositoryPermissions[i].UserID]; ok { - resolved.UserRepositoryPermissions[i].UserID = mappedUser + for i := range cfg.UserRepositoryPermissions { + if mappedUser, ok := state.userIDMap[cfg.UserRepositoryPermissions[i].UserID]; ok { + cfg.UserRepositoryPermissions[i].UserID = mappedUser } - if mappedRepo, ok := repositoryNameMap[resolved.UserRepositoryPermissions[i].Repository]; ok { - resolved.UserRepositoryPermissions[i].Repository = mappedRepo + if mappedRepo, ok := state.repositoryNameMap[cfg.UserRepositoryPermissions[i].Repository]; ok { + cfg.UserRepositoryPermissions[i].Repository = mappedRepo } } - - clearNameModes(resolved) - - return resolved, appliedSuffix, nil } // normalizeNameMode validates and normalizes a nameMode value.