-
Notifications
You must be signed in to change notification settings - Fork 2
feat: support random suffix #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||||||||||||||||
|
|
@@ -54,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) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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)) | ||||||||||||||||||||
| if err := resolveUsers(resolved.Users, defaultMode, suffix, state); err != nil { | ||||||||||||||||||||
| return nil, false, err | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Critical Issue ( Suggestion: Always ensure exactly
Suggested change
|
||||||||||||||||||||
| 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 | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| for i, user := range resolved.Users { | ||||||||||||||||||||
| mode, err := normalizeNameMode(user.NameMode, defaultMode) | ||||||||||||||||||||
| rewriteResolvedReferences(resolved, state) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| 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 | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // 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. | ||||||||||||||||||||
|
|
@@ -204,6 +264,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 { | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion (
style/robustness): Consider validatinglengthearly for invalid values (e.g., > 100) to fail fast.