From e1836e34bb0b5f103869a341b70fe24f528d95d6 Mon Sep 17 00:00:00 2001 From: Zichen Yu <1062955096@qq.com> Date: Thu, 30 Apr 2026 10:31:37 +0800 Subject: [PATCH] feat: support random suffix --- CLAUDE.md | 6 +- README.md | 17 ++++- docs/TEMPLATE.md | 15 ++-- internal/cli/cmd.go | 9 ++- internal/config/config.go | 1 + internal/processor/processor.go | 27 ++++--- internal/utils/utils.go | 127 ++++++++++++++++++++++---------- 7 files changed, 137 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 629f2f4..e7f4781 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ The codebase follows a strict layered architecture (top to bottom): - `internal/config`: Configuration loading and credential management - `internal/processor`: Business logic for creating/deleting resources - `internal/template`: Go template rendering for custom output - - `internal/utils`: Utility functions (timestamp generation, visibility conversion) + - `internal/utils`: Utility functions (millisecond timestamp + suffix generation, visibility conversion) - **`pkg/`**: Public packages that can be imported by external projects - `pkg/client`: GitLab API client wrapper (built on official SDK) @@ -112,8 +112,8 @@ The codebase follows a strict layered architecture (top to bottom): The tool supports two naming modes for resources: -- **`prefix` mode (default)**: Appends timestamp to username/email/group/project paths - - Example: `tektoncd` → `tektoncd-20251030150000` +- **`prefix` mode (default)**: Appends `millisecond timestamp + suffix` to username/email/group/project paths + - Example: `tektoncd` → `tektoncd-20251030150000123-a1b2` - Used for test environments and creating multiple similar resources - **Important**: Cleanup must use the output file from creation (not config file) diff --git a/README.md b/README.md index 7f00f68..b6b8879 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ export GITLAB_SSH_ENDPOINT=ssh://git@your-gitlab.com:ssh-port --token your-token \ -f config.yaml +# Optional: specify a fixed suffix in prefix mode +./bin/gitlab-cli user create \ + --host https://your-gitlab.com \ + --token your-token \ + --suffix ci01 \ + -f config.yaml + # Output results to file ./bin/gitlab-cli user create \ --host https://your-gitlab.com \ @@ -72,8 +79,8 @@ export GITLAB_SSH_ENDPOINT=ssh://git@your-gitlab.com:ssh-port -f config.yaml # ⚠️ Note: Cleanup with prefix mode -# When using nameMode: prefix (adds timestamp), cleanup requires the output file from creation -# Because actual usernames, group names, and project names all include timestamps +# When using nameMode: prefix (adds millisecond timestamp + suffix), cleanup requires the output file from creation +# Because actual usernames, group names, and project names include generated suffixes # 1. Save output file during creation ./bin/gitlab-cli user create \ @@ -104,8 +111,10 @@ export GITLAB_SSH_ENDPOINT=ssh://git@your-gitlab.com:ssh-port The configuration file supports two naming modes: **1. prefix mode (default)** -- Automatically appends timestamps to username, email, group path, and project path -- Example: `tektoncd` → `tektoncd-20251030150000` +- Automatically appends `millisecond timestamp + suffix` to username, email, group path, and project path +- Default format: `-` +- Example: `tektoncd` → `tektoncd-20251030150000123-a1b2` +- Optional override: use `--suffix ` to replace the random suffix - Use cases: Test environments, creating multiple similar resources - ⚠️ Cleanup must use the output file from creation diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index 418c8fe..db03bf9 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -21,6 +21,7 @@ GitLab CLI 支持使用自定义模板来格式化输出结果,让你可以按 - `-f, --config`: 输入配置文件(用户、组、项目定义) - `-o, --output`: 输出文件路径 - `-t, --template`: 模板文件路径(可选) +- `--suffix`: Optional custom suffix used in `nameMode: prefix` (replaces random suffix part) **注意**: - 如果不指定 `--template`,将使用默认的 YAML 格式输出 @@ -35,17 +36,17 @@ GitLab CLI 支持使用自定义模板来格式化输出结果,让你可以按 在输出数据中,项目有两个路径字段: - **Path**: 完整路径,包含 group 或 username 前缀 - - 组级项目:`backend-group-20251105112212/demo-20251105112213` - - 用户级项目:`tektoncd-20251105112211/my-personal-project-20251105112216` + - 组级项目:`backend-group-20251105112212123-a1b2/demo-20251105112213123-c3d4` + - 用户级项目:`tektoncd-20251105112211123-a1b2/my-personal-project-20251105112216123-c3d4` - **ProjectPath**: 项目本身的路径,不包含前缀 - - 组级项目:`demo-20251105112213` - - 用户级项目:`my-personal-project-20251105112216` + - 组级项目:`demo-20251105112213123-c3d4` + - 用户级项目:`my-personal-project-20251105112216123-c3d4` **关于时间戳后缀**: -- 当使用 `nameMode: prefix` 时(默认),路径会自动添加时间戳后缀 -- 当使用 `nameMode: name` 时,路径保持与配置文件中一致,不添加时间戳 -- **注意**:nameMode 只影响路径(Path),不影响项目的显示名称(Name) +- When `nameMode: prefix` is used (default), paths automatically append `millisecond timestamp + suffix` +- When `nameMode: name` is used, paths stay exactly as configured +- `nameMode` only affects path fields (`Path` / `ProjectPath`), not display names (`Name`) ### 可用数据 diff --git a/internal/cli/cmd.go b/internal/cli/cmd.go index 6122969..f25b12f 100644 --- a/internal/cli/cmd.go +++ b/internal/cli/cmd.go @@ -75,6 +75,7 @@ func buildUserCreateCommand(cfg *config.CLIConfig) *cobra.Command { cmd.Flags().StringVar(&cfg.GitLabSSHEndpoint, "ssh-endpoint", "", "GitLab SSH endpoint (e.g., ssh://git@host:22)") cmd.Flags().StringVarP(&cfg.OutputFile, "output", "o", "", "输出结果到 YAML 文件") cmd.Flags().StringVarP(&cfg.TemplateFile, "template", "t", "", "使用模板文件格式化输出") + cmd.Flags().StringVar(&cfg.NameSuffix, "suffix", "", "Custom suffix appended after millisecond timestamp in prefix mode") return cmd } @@ -145,8 +146,14 @@ func runUserCreate(cfg *config.CLIConfig) error { } log.Printf("\n找到 %d 个用户配置\n\n", len(userConfig.Users)) + if cfg.NameSuffix != "" { + log.Printf("使用自定义后缀: %s\n", cfg.NameSuffix) + } - proc := &processor.ResourceProcessor{Client: gitlabClient} + proc := &processor.ResourceProcessor{ + Client: gitlabClient, + NameSuffix: cfg.NameSuffix, + } // 收集所有用户的输出结果 var userOutputs []types.UserOutput diff --git a/internal/config/config.go b/internal/config/config.go index 22ad6ba..e9a5be1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type CLIConfig struct { TemplateFile string // 模板文件路径 DaysOld int // 只删除创建日期超过指定天数的用户(cleanup 命令使用) GitLabSSHEndpoint string // GitLab SSH endpoint (e.g., ssh://git@host:22) + NameSuffix string // Optional custom suffix used in prefix naming mode. } // LoadGitLabCredentials 从环境变量或命令行参数加载 GitLab 凭证 diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 0de72c8..faafa3b 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -12,7 +12,10 @@ import ( // ResourceProcessor 封装资源创建和删除的业务逻辑 type ResourceProcessor struct { + // Client handles GitLab API interactions. Client *client.GitLabClient + // NameSuffix optionally overrides the random suffix appended in prefix mode. + NameSuffix string } // ======================================== @@ -35,10 +38,10 @@ func (p *ResourceProcessor) ProcessUserCreation(userSpec types.UserSpec) (*types actualEmail = userSpec.Email log.Printf(" 使用 name 模式(不添加时间戳)\n") } else { - // prefix 模式:添加时间戳 - actualUsername = utils.GenerateUsernameWithTimestamp(userSpec.Username) - actualEmail = utils.GenerateEmailWithTimestamp(userSpec.Email) - log.Printf(" 使用 prefix 模式(添加时间戳)\n") + // prefix 模式:添加毫秒时间戳和后缀 + actualUsername = utils.GenerateUsernameWithTimestamp(userSpec.Username, p.NameSuffix) + actualEmail = utils.GenerateEmailWithTimestamp(userSpec.Email, p.NameSuffix) + log.Printf(" 使用 prefix 模式(添加毫秒时间戳+后缀)\n") } log.Printf(" 用户名: %s\n", actualUsername) @@ -103,8 +106,8 @@ func (p *ResourceProcessor) ProcessUserCreation(userSpec types.UserSpec) (*types // createPersonalAccessToken 为用户创建 Personal Access Token,返回 token 值和实际使用的过期时间 func (p *ResourceProcessor) createPersonalAccessToken(userID int, username string, tokenSpec *types.TokenSpec) (string, string, error) { - // 生成 token 名称,格式: username-token-timestamp - tokenName := fmt.Sprintf("%s-token-%d", username, time.Now().Unix()) + // 生成 token 名称,格式: username-token-- + tokenName := fmt.Sprintf("%s-token-%s", username, utils.GenerateTemporalSuffix(p.NameSuffix)) // 设置过期时间:如果未指定,默认为第2天 expiresAt := tokenSpec.ExpiresAt @@ -207,9 +210,9 @@ func (p *ResourceProcessor) ensureGroup(username string, groupSpec types.GroupSp } else { // prefix 模式:添加时间戳 if groupSpec.Path == "" { - actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Name) + actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Name, p.NameSuffix) } else { - actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Path) + actualGroupPath = utils.GenerateGroupPathWithTimestamp(groupSpec.Path, p.NameSuffix) } log.Printf(" 使用 prefix 模式,生成组 path: %s\n", actualGroupPath) } @@ -267,9 +270,9 @@ func (p *ResourceProcessor) createUserProjectsWithOutput(username string, projec } else { // prefix 模式:添加时间戳 if projSpec.Path == "" { - actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name) + actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name, p.NameSuffix) } else { - actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path) + actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path, p.NameSuffix) } log.Printf(" 使用 prefix 模式,生成项目 path: %s\n", actualProjectPath) } @@ -341,9 +344,9 @@ func (p *ResourceProcessor) createProjectsWithOutput(username string, groupID in } else { // prefix 模式:添加时间戳 if projSpec.Path == "" { - actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name) + actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Name, p.NameSuffix) } else { - actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path) + actualProjectPath = utils.GenerateProjectPathWithTimestamp(projSpec.Path, p.NameSuffix) } log.Printf(" 使用 prefix 模式,生成项目 path: %s\n", actualProjectPath) } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index f5581a5..e336702 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -1,13 +1,24 @@ package utils import ( + "crypto/rand" "fmt" "regexp" "strings" "time" ) -// GetVisibility 获取可见性设置,默认为 private +const ( + // defaultRandomSuffixLength controls the random suffix length when no custom suffix is provided. + defaultRandomSuffixLength = 4 +) + +var ( + // shortSuffixAlphabet contains safe characters for username/email/path suffixes. + shortSuffixAlphabet = []byte("abcdefghijklmnopqrstuvwxyz0123456789") +) + +// GetVisibility returns the visibility value, defaulting to private. func GetVisibility(v string) string { if v == "" { return "private" @@ -15,86 +26,126 @@ func GetVisibility(v string) string { return v } -// GenerateTimestampSuffix 生成时间戳后缀(格式:20060102150405) +// GenerateTimestampSuffix returns a millisecond-level timestamp suffix in yyyyMMddHHmmssSSS format. func GenerateTimestampSuffix() string { - return time.Now().Format("20060102150405") + now := time.Now() + millisecond := now.Nanosecond() / int(time.Millisecond) + return fmt.Sprintf("%s%03d", now.Format("20060102150405"), millisecond) +} + +// GenerateTemporalSuffix returns a unique suffix in the format timestamp-randomOrCustom. +func GenerateTemporalSuffix(customSuffix string) string { + normalizedSuffix := normalizeCustomSuffix(customSuffix) + if normalizedSuffix == "" { + normalizedSuffix = generateShortRandomSuffix(defaultRandomSuffixLength) + } + return fmt.Sprintf("%s-%s", GenerateTimestampSuffix(), normalizedSuffix) } -// GenerateUsernameWithTimestamp 基于前缀生成符合 GitLab 要求的 username -// 格式:prefix-timestamp -func GenerateUsernameWithTimestamp(prefix string) string { - timestamp := GenerateTimestampSuffix() - username := fmt.Sprintf("%s-%s", prefix, timestamp) - // 确保符合 GitLab username 规则:只能包含字母、数字、下划线、点号、破折号 +// GenerateUsernameWithTimestamp builds a GitLab-safe username as prefix-temporalSuffix. +func GenerateUsernameWithTimestamp(prefix, customSuffix string) string { + temporalSuffix := GenerateTemporalSuffix(customSuffix) + username := fmt.Sprintf("%s-%s", prefix, temporalSuffix) + // Ensure username follows GitLab username rules. username = sanitizeUsername(username) - // 限制长度为 255 + // Enforce GitLab username max length. if len(username) > 255 { username = username[:255] } return username } -// GenerateEmailWithTimestamp 基于前缀生成唯一的 email -// 格式:prefix-timestamp@domain -func GenerateEmailWithTimestamp(emailPrefix string) string { - // 解析邮箱前缀和域名 +// GenerateEmailWithTimestamp builds an email as localPart-temporalSuffix@domain. +func GenerateEmailWithTimestamp(emailPrefix, customSuffix string) string { + // Split local part and domain. parts := strings.Split(emailPrefix, "@") if len(parts) != 2 { - // 如果不是有效的邮箱格式,使用默认域名 - return fmt.Sprintf("%s-%s@test.example.com", emailPrefix, GenerateTimestampSuffix()) + // Fall back to a default test domain when input is not an email. + return fmt.Sprintf("%s-%s@test.example.com", emailPrefix, GenerateTemporalSuffix(customSuffix)) } localPart := parts[0] domain := parts[1] - timestamp := GenerateTimestampSuffix() + temporalSuffix := GenerateTemporalSuffix(customSuffix) - return fmt.Sprintf("%s-%s@%s", localPart, timestamp, domain) + return fmt.Sprintf("%s-%s@%s", localPart, temporalSuffix, domain) } -// GenerateGroupPathWithTimestamp 基于前缀生成符合 GitLab 要求的 group path -// 格式:prefix-timestamp -func GenerateGroupPathWithTimestamp(prefix string) string { - timestamp := GenerateTimestampSuffix() - path := fmt.Sprintf("%s-%s", prefix, timestamp) - // 确保符合 GitLab group path 规则:只能包含小写字母、数字、下划线、破折号 +// GenerateGroupPathWithTimestamp builds a GitLab-safe group path as prefix-temporalSuffix. +func GenerateGroupPathWithTimestamp(prefix, customSuffix string) string { + temporalSuffix := GenerateTemporalSuffix(customSuffix) + path := fmt.Sprintf("%s-%s", prefix, temporalSuffix) + // Ensure path follows GitLab group path rules. path = sanitizeGroupPath(path) - // 限制长度为 255 + // Enforce GitLab path max length. if len(path) > 255 { path = path[:255] } return path } -// GenerateProjectPathWithTimestamp 基于前缀生成符合 GitLab 要求的 project path -// 格式:prefix-timestamp(项目 path 规则与组 path 相同) -func GenerateProjectPathWithTimestamp(prefix string) string { - return GenerateGroupPathWithTimestamp(prefix) +// GenerateProjectPathWithTimestamp builds a GitLab-safe project path as prefix-temporalSuffix. +func GenerateProjectPathWithTimestamp(prefix, customSuffix string) string { + return GenerateGroupPathWithTimestamp(prefix, customSuffix) +} + +// normalizeCustomSuffix sanitizes a custom suffix to safe characters and lowercase. +func normalizeCustomSuffix(customSuffix string) string { + trimmedSuffix := strings.TrimSpace(customSuffix) + if trimmedSuffix == "" { + return "" + } + + safeSuffix := regexp.MustCompile(`[^a-zA-Z0-9_-]`).ReplaceAllString(trimmedSuffix, "") + return strings.ToLower(safeSuffix) +} + +// generateShortRandomSuffix returns a random lowercase alphanumeric suffix. +func generateShortRandomSuffix(length int) string { + if length <= 0 { + length = defaultRandomSuffixLength + } + + rawRandomBytes := make([]byte, length) + _, randomErr := rand.Read(rawRandomBytes) + if randomErr != 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) } -// sanitizeUsername 清理 username,确保符合 GitLab 规则 -// 允许:字母、数字、下划线、点号、破折号 +// sanitizeUsername sanitizes username to comply with GitLab username rules. func sanitizeUsername(username string) string { - // 移除不允许的字符 + // Remove unsupported characters. reg := regexp.MustCompile(`[^a-zA-Z0-9_.-]`) username = reg.ReplaceAllString(username, "") - // 确保不以破折号或点号开头/结尾 + // Ensure username does not start/end with hyphen or dot. username = strings.Trim(username, "-.") return username } -// sanitizeGroupPath 清理 group path,确保符合 GitLab 规则 -// 允许:小写字母、数字、下划线、破折号 +// sanitizeGroupPath sanitizes group/project path to comply with GitLab path rules. func sanitizeGroupPath(path string) string { - // 转换为小写 + // Normalize to lowercase. path = strings.ToLower(path) - // 移除不允许的字符 + // Remove unsupported characters. reg := regexp.MustCompile(`[^a-z0-9_-]`) path = reg.ReplaceAllString(path, "") - // 确保不以破折号开头/结尾 + // Ensure path does not start/end with hyphen. path = strings.Trim(path, "-") return path