Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
217 changes: 150 additions & 67 deletions pkg/config/name_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
package config

import (
"crypto/rand"
"fmt"
"strings"
"time"
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (style/robustness): Consider validating length early for invalid values (e.g., > 100) to fail fast.

// 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical Issue (bug/function-contract): The fallback logic here may return a string shorter than length. When len(fallbackFromTime) <= length, the function returns the entire fallbackFromTime (which is ~19 characters for UnixNano()), violating the function contract that promises exactly length characters.

Suggestion: Always ensure exactly length characters in fallback:

Suggested change
}
if _, err := randomRead(rawRandomBytes); err != nil {
fallbackFromTime := fmt.Sprintf("%d", time.Now().UnixNano())
// Pad or truncate to ensure exactly `length` characters
for len(fallbackFromTime) < length {
fallbackFromTime += fallbackFromTime
}
return fallbackFromTime[:length]
}

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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading