diff --git a/README.md b/README.md index 125e70e..32b31ea 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,42 @@ userRepositoryPermissions: Refer to `config/example.yaml` for more configuration options. +### NameMode (Optional) + +`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`) + +You can set `nameMode` globally and override it per resource: + +```yaml +nameMode: "suffix" + +users: + - id: "repo-admin" + emailAddress: "repo-admin@example.com" + roles: + - "repository-manager" + +repositories: + - nameMode: "name" # override global suffix mode for this repository + name: "maven-releases" + format: "maven2" + type: "hosted" + online: true + storage: + blobStoreName: "default" + strictContentTypeValidation: true +``` + +To reuse the same resolved names in later cleanup workflows, persist the resolved config: + +```bash +nexus-cli create -c my-config.yaml --resolved-config resolved-config.yaml +nexus-cli delete -c resolved-config.yaml +``` + ## Apply Configuration ```bash diff --git a/cmd/create.go b/cmd/create.go index 36ee396..2ff384f 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -20,10 +20,16 @@ import ( ) var ( - outputFormat string + // outputFormat stores the formatter output mode selected by CLI flag. + outputFormat string + // outputTemplate stores the template file path used by template output mode. outputTemplate string - outputFile string - quiet bool + // outputFile stores the destination file path for command output. + outputFile string + // resolvedConfigPath stores the destination file path of resolved config. + resolvedConfigPath string + // quiet controls whether info logs are suppressed. + quiet bool ) var createCmd = &cobra.Command{ @@ -62,6 +68,7 @@ func init() { createCmd.Flags().StringVarP(&outputFormat, "output", "o", "text", "Output format (text|json|yaml|template|table)") createCmd.Flags().StringVar(&outputTemplate, "output-template", "", "Template file to format resource output") createCmd.Flags().StringVar(&outputFile, "output-file", "", "File to write resource output (stdout if not specified)") + createCmd.Flags().StringVar(&resolvedConfigPath, "resolved-config", "", "File to write resolved config used by create/delete after nameMode processing") createCmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Quiet mode - only show errors") } @@ -115,8 +122,23 @@ func runCreate(_ *cobra.Command, _ []string) error { formatter.Info(fmt.Sprintf("Loaded configuration from %s", cfgFile)) + // Resolve nameMode before applying resources. + resolvedCfg, suffix, err := cfg.ResolveNames() + if err != nil { + return fmt.Errorf("failed to resolve config names: %w", err) + } + if suffix != "" { + formatter.Info(fmt.Sprintf("Resolved nameMode=suffix with generated suffix: %s", suffix)) + } + if resolvedConfigPath != "" { + if err := config.Save(resolvedConfigPath, resolvedCfg); err != nil { + return fmt.Errorf("failed to save resolved config: %w", err) + } + formatter.Info(fmt.Sprintf("Resolved config written to %s", resolvedConfigPath)) + } + // 创建服务并执行 - svc := service.NewApplyService(client, cfg, formatter) + svc := service.NewApplyService(client, resolvedCfg, formatter) result, err := svc.Apply() if err != nil { return fmt.Errorf("failed to create resources: %w", err) @@ -146,9 +168,9 @@ func runCreate(_ *cobra.Command, _ []string) error { if outputTemplate != "" || outputFile != "" { // 如果没有指定模板,使用默认的 YAML 格式输出(与配置文件格式一致) if outputTemplate == "" { - return outputResourcesDefault(client, cfg, outputFile) + return outputResourcesDefault(client, resolvedCfg, outputFile) } - return outputResources(client, cfg, outputTemplate, outputFile) + return outputResources(client, resolvedCfg, outputTemplate, outputFile) } return nil diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 62722c7..cdbad80 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -4,6 +4,7 @@ package config import ( "fmt" "os" + "path/filepath" "gopkg.in/yaml.v3" ) @@ -23,6 +24,26 @@ func Load(filepath string) (*Config, error) { return &config, nil } +// Save writes configuration to file in YAML format. +func Save(path string, cfg *Config) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + // GetNexusCredentials 从环境变量获取 Nexus 认证信息 func GetNexusCredentials() (url, username, password string, err error) { url = os.Getenv("NEXUS_URL") diff --git a/pkg/config/name_resolver.go b/pkg/config/name_resolver.go new file mode 100644 index 0000000..6301b9e --- /dev/null +++ b/pkg/config/name_resolver.go @@ -0,0 +1,315 @@ +// Package config provides configuration types and loading functionality. +package config + +import ( + "fmt" + "strings" + "time" +) + +const ( + // NameModeName keeps the configured resource names unchanged. + NameModeName = "name" + // NameModeSuffix appends a generated timestamp suffix to configured resource names. + NameModeSuffix = "suffix" +) + +// ResolveNames resolves nameMode for all supported resources and rewrites references. +// It returns the resolved configuration and the generated suffix. +// The returned suffix is empty when no suffix mode is applied. +func (c *Config) ResolveNames() (*Config, string, error) { + suffix := GenerateTimestampSuffix() + resolved, applied, err := c.resolveNamesWithSuffix(suffix) + if err != nil { + return nil, "", err + } + if !applied { + return resolved, "", nil + } + return resolved, suffix, nil +} + +// ResolveNamesWithSuffix resolves nameMode with a caller-provided suffix. +// This helper is mainly intended for deterministic tests. +func (c *Config) ResolveNamesWithSuffix(suffix string) (*Config, error) { + resolved, _, err := c.resolveNamesWithSuffix(suffix) + if err != nil { + return nil, err + } + return resolved, nil +} + +// GenerateTimestampSuffix returns timestamp in the same format used by gitlab-cli. +func GenerateTimestampSuffix() string { + return time.Now().Format("20060102150405") +} + +// resolveNamesWithSuffix performs a full nameMode resolution and returns whether suffix mode was applied. +func (c *Config) resolveNamesWithSuffix(suffix string) (*Config, bool, error) { + if c == nil { + return &Config{}, false, nil + } + + defaultMode, err := normalizeNameMode(c.NameMode, NameModeName) + 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 + + 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)) + + for i, user := range resolved.Users { + mode, err := normalizeNameMode(user.NameMode, defaultMode) + 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") + } + + actualID := user.ID + if mode == NameModeSuffix { + appliedSuffix = true + actualID = withSuffix(user.ID, suffix) + resolved.Users[i].ID = actualID + resolved.Users[i].EmailAddress = emailWithSuffix(user.EmailAddress, suffix) + } + userIDMap[user.ID] = actualID + } + + for i, repository := range resolved.Repositories { + mode, err := normalizeNameMode(repository.NameMode, defaultMode) + 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") + } + + actualName := repository.Name + if mode == NameModeSuffix { + appliedSuffix = true + actualName = withSuffix(repository.Name, suffix) + resolved.Repositories[i].Name = actualName + } + repositoryNameMap[repository.Name] = actualName + } + + for i, privilege := range resolved.Privileges { + mode, err := normalizeNameMode(privilege.NameMode, defaultMode) + 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") + } + + actualName := privilege.Name + if mode == NameModeSuffix { + appliedSuffix = true + actualName = withSuffix(privilege.Name, suffix) + resolved.Privileges[i].Name = actualName + } + privilegeNameMap[privilege.Name] = actualName + } + + for i, role := range resolved.Roles { + mode, err := normalizeNameMode(role.NameMode, defaultMode) + 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") + } + + actualID := role.ID + if mode == NameModeSuffix { + appliedSuffix = true + actualID = withSuffix(role.ID, suffix) + resolved.Roles[i].ID = actualID + resolved.Roles[i].Name = withSuffix(role.Name, suffix) + } + roleIDMap[role.ID] = actualID + } + + for i := range resolved.Users { + resolved.Users[i].Roles = remapStrings(resolved.Users[i].Roles, 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 resolved.Privileges { + if mappedRepo, ok := repositoryNameMap[resolved.Privileges[i].Repository]; ok { + resolved.Privileges[i].Repository = mappedRepo + } + } + + for i := range resolved.UserRepositoryPermissions { + if mappedUser, ok := userIDMap[resolved.UserRepositoryPermissions[i].UserID]; ok { + resolved.UserRepositoryPermissions[i].UserID = mappedUser + } + if mappedRepo, ok := repositoryNameMap[resolved.UserRepositoryPermissions[i].Repository]; ok { + resolved.UserRepositoryPermissions[i].Repository = mappedRepo + } + } + + clearNameModes(resolved) + + return resolved, appliedSuffix, nil +} + +// normalizeNameMode validates and normalizes a nameMode value. +func normalizeNameMode(mode string, fallback string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(mode)) + if normalized == "" { + normalized = fallback + } + switch normalized { + case NameModeName, NameModeSuffix: + return normalized, nil + default: + return "", fmt.Errorf("unsupported nameMode %q (allowed: %q, %q)", mode, NameModeName, NameModeSuffix) + } +} + +// withSuffix appends suffix in "-" format. +func withSuffix(value string, suffix string) string { + if value == "" || suffix == "" { + return value + } + return fmt.Sprintf("%s-%s", value, suffix) +} + +// emailWithSuffix appends suffix to email local-part to avoid collisions. +func emailWithSuffix(email string, suffix string) string { + if email == "" || suffix == "" { + return email + } + at := strings.Index(email, "@") + if at < 0 { + return withSuffix(email, suffix) + } + return fmt.Sprintf("%s+%s%s", email[:at], suffix, email[at:]) +} + +// remapStrings remaps values using map and keeps unknown values unchanged. +func remapStrings(values []string, mapping map[string]string) []string { + if len(values) == 0 { + return values + } + remapped := make([]string, 0, len(values)) + for _, value := range values { + if mapped, ok := mapping[value]; ok { + remapped = append(remapped, mapped) + continue + } + remapped = append(remapped, value) + } + return remapped +} + +// clearNameModes removes nameMode fields from a resolved config so it can be safely reused. +func clearNameModes(cfg *Config) { + if cfg == nil { + return + } + + cfg.NameMode = "" + for i := range cfg.Users { + cfg.Users[i].NameMode = "" + } + for i := range cfg.Repositories { + cfg.Repositories[i].NameMode = "" + } + for i := range cfg.Privileges { + cfg.Privileges[i].NameMode = "" + } + for i := range cfg.Roles { + cfg.Roles[i].NameMode = "" + } +} + +// cloneConfig creates a deep copy for all mutable slices. +func cloneConfig(in *Config) *Config { + if in == nil { + return &Config{} + } + + out := &Config{ + NameMode: in.NameMode, + Users: make([]User, len(in.Users)), + Repositories: make([]Repository, len(in.Repositories)), + Privileges: make([]Privilege, len(in.Privileges)), + Roles: make([]Role, len(in.Roles)), + UserRepositoryPermissions: make([]UserRepositoryPermission, len(in.UserRepositoryPermissions)), + } + + for i, user := range in.Users { + out.Users[i] = user + out.Users[i].Roles = copyStrings(user.Roles) + } + for i, repository := range in.Repositories { + out.Repositories[i] = repository + if repository.Proxy != nil { + proxyCopy := *repository.Proxy + if repository.Proxy.Authentication != nil { + authCopy := *repository.Proxy.Authentication + proxyCopy.Authentication = &authCopy + } + out.Repositories[i].Proxy = &proxyCopy + } + if repository.Maven != nil { + mavenCopy := *repository.Maven + out.Repositories[i].Maven = &mavenCopy + } + if repository.Docker != nil { + dockerCopy := *repository.Docker + out.Repositories[i].Docker = &dockerCopy + } + if repository.Apt != nil { + aptCopy := *repository.Apt + out.Repositories[i].Apt = &aptCopy + } + if repository.Cleanup != nil { + cleanupCopy := *repository.Cleanup + cleanupCopy.PolicyNames = copyStrings(repository.Cleanup.PolicyNames) + out.Repositories[i].Cleanup = &cleanupCopy + } + } + for i, privilege := range in.Privileges { + out.Privileges[i] = privilege + out.Privileges[i].Actions = copyStrings(privilege.Actions) + } + for i, role := range in.Roles { + out.Roles[i] = role + out.Roles[i].Privileges = copyStrings(role.Privileges) + out.Roles[i].Roles = copyStrings(role.Roles) + } + for i, permission := range in.UserRepositoryPermissions { + out.UserRepositoryPermissions[i] = permission + out.UserRepositoryPermissions[i].Privileges = copyStrings(permission.Privileges) + } + return out +} + +// copyStrings clones a string slice. +func copyStrings(in []string) []string { + if in == nil { + return nil + } + out := make([]string, len(in)) + copy(out, in) + return out +} diff --git a/pkg/config/name_resolver_test.go b/pkg/config/name_resolver_test.go new file mode 100644 index 0000000..524275b --- /dev/null +++ b/pkg/config/name_resolver_test.go @@ -0,0 +1,427 @@ +package config + +import ( + "reflect" + "regexp" + "strings" + "testing" +) + +// TestResolveNamesWithSuffixSuffixMode verifies suffix mode rewrites all managed names and references. +func TestResolveNamesWithSuffixSuffixMode(t *testing.T) { + const suffix = "20260324112233" + + cfg := &Config{ + NameMode: "suffix", + Users: []User{ + { + ID: "repo-admin", + EmailAddress: "repo-admin@example.com", + Roles: []string{"repository-manager", "nx-admin"}, + }, + }, + Repositories: []Repository{ + {Name: "maven-releases"}, + }, + Privileges: []Privilege{ + { + Name: "maven-deploy", + Repository: "maven-releases", + Actions: []string{"READ"}, + }, + }, + Roles: []Role{ + { + ID: "repository-manager", + Name: "Repository Manager", + Privileges: []string{"maven-deploy", "nx-repository-view-*-*-read"}, + Roles: []string{"nx-admin"}, + }, + }, + UserRepositoryPermissions: []UserRepositoryPermission{ + { + UserID: "repo-admin", + Repository: "maven-releases", + Privileges: []string{"READ", "BROWSE"}, + }, + }, + } + + resolved, err := cfg.ResolveNamesWithSuffix(suffix) + if err != nil { + t.Fatalf("ResolveNamesWithSuffix() error = %v", err) + } + + if got, want := resolved.Users[0].ID, "repo-admin-"+suffix; got != want { + t.Fatalf("resolved user id = %q, want %q", got, want) + } + if got, want := resolved.Users[0].EmailAddress, "repo-admin+"+suffix+"@example.com"; got != want { + t.Fatalf("resolved user email = %q, want %q", got, want) + } + if got, want := resolved.Users[0].Roles[0], "repository-manager-"+suffix; got != want { + t.Fatalf("resolved user role ref = %q, want %q", got, want) + } + if got, want := resolved.Users[0].Roles[1], "nx-admin"; got != want { + t.Fatalf("resolved built-in user role ref = %q, want %q", got, want) + } + + if got, want := resolved.Repositories[0].Name, "maven-releases-"+suffix; got != want { + t.Fatalf("resolved repository = %q, want %q", got, want) + } + + if got, want := resolved.Privileges[0].Name, "maven-deploy-"+suffix; got != want { + t.Fatalf("resolved privilege name = %q, want %q", got, want) + } + if got, want := resolved.Privileges[0].Repository, "maven-releases-"+suffix; got != want { + t.Fatalf("resolved privilege repository = %q, want %q", got, want) + } + + if got, want := resolved.Roles[0].ID, "repository-manager-"+suffix; got != want { + t.Fatalf("resolved role id = %q, want %q", got, want) + } + if got, want := resolved.Roles[0].Name, "Repository Manager-"+suffix; got != want { + t.Fatalf("resolved role name = %q, want %q", got, want) + } + if got, want := resolved.Roles[0].Privileges[0], "maven-deploy-"+suffix; got != want { + t.Fatalf("resolved role privilege ref = %q, want %q", got, want) + } + if got, want := resolved.Roles[0].Privileges[1], "nx-repository-view-*-*-read"; got != want { + t.Fatalf("resolved built-in role privilege ref = %q, want %q", got, want) + } + if got, want := resolved.Roles[0].Roles[0], "nx-admin"; got != want { + t.Fatalf("resolved built-in role ref = %q, want %q", got, want) + } + + if got, want := resolved.UserRepositoryPermissions[0].UserID, "repo-admin-"+suffix; got != want { + t.Fatalf("resolved permission userId = %q, want %q", got, want) + } + if got, want := resolved.UserRepositoryPermissions[0].Repository, "maven-releases-"+suffix; got != want { + t.Fatalf("resolved permission repository = %q, want %q", got, want) + } + + if got, want := cfg.Users[0].ID, "repo-admin"; got != want { + t.Fatalf("source config should stay unchanged, user id = %q, want %q", got, want) + } + if got, want := cfg.Repositories[0].Name, "maven-releases"; got != want { + t.Fatalf("source config should stay unchanged, repo name = %q, want %q", got, want) + } +} + +func TestResolveNamesWithSuffixNoChangeByDefault(t *testing.T) { + cfg := &Config{ + Users: []User{ + {ID: "repo-admin", EmailAddress: "repo-admin@example.com", Roles: []string{"repository-manager"}}, + }, + Repositories: []Repository{ + {Name: "maven-releases"}, + }, + Roles: []Role{ + {ID: "repository-manager", Name: "Repository Manager"}, + }, + } + + resolved, err := cfg.ResolveNamesWithSuffix("20260324112233") + if err != nil { + t.Fatalf("ResolveNamesWithSuffix() error = %v", err) + } + + if got, want := resolved.Users[0].ID, "repo-admin"; got != want { + t.Fatalf("resolved user id = %q, want %q", got, want) + } + if got, want := resolved.Repositories[0].Name, "maven-releases"; got != want { + t.Fatalf("resolved repository = %q, want %q", got, want) + } + if got, want := resolved.Roles[0].ID, "repository-manager"; got != want { + t.Fatalf("resolved role id = %q, want %q", got, want) + } +} + +// TestResolveNamesWithSuffixPreservesExplicitEmptyUserRoles ensures explicit empty roles keep [] instead of nil. +func TestResolveNamesWithSuffixPreservesExplicitEmptyUserRoles(t *testing.T) { + cfg := &Config{ + Users: []User{ + {ID: "repo-admin", Roles: []string{}}, + }, + } + + resolved, err := cfg.ResolveNamesWithSuffix("20260324112233") + if err != nil { + t.Fatalf("ResolveNamesWithSuffix() error = %v", err) + } + + if resolved.Users[0].Roles == nil { + t.Fatal("resolved users[0].roles = nil, want explicit empty slice") + } + if len(resolved.Users[0].Roles) != 0 { + t.Fatalf("resolved users[0].roles length = %d, want 0", len(resolved.Users[0].Roles)) + } + if cfg.Users[0].Roles == nil { + t.Fatal("source users[0].roles = nil, want explicit empty slice") + } +} + +func TestResolveNamesWithSuffixResourceOverride(t *testing.T) { + const suffix = "20260324112233" + + cfg := &Config{ + NameMode: "name", + Users: []User{ + {ID: "repo-admin", Roles: []string{"repository-manager"}}, + }, + Roles: []Role{ + {ID: "repository-manager", Name: "Repository Manager"}, + }, + Repositories: []Repository{ + {NameMode: "suffix", Name: "maven-releases"}, + }, + UserRepositoryPermissions: []UserRepositoryPermission{ + {UserID: "repo-admin", Repository: "maven-releases"}, + }, + } + + resolved, err := cfg.ResolveNamesWithSuffix(suffix) + if err != nil { + t.Fatalf("ResolveNamesWithSuffix() error = %v", err) + } + + if got, want := resolved.Users[0].ID, "repo-admin"; got != want { + t.Fatalf("resolved user id = %q, want %q", got, want) + } + if got, want := resolved.Repositories[0].Name, "maven-releases-"+suffix; got != want { + t.Fatalf("resolved repository = %q, want %q", got, want) + } + if got, want := resolved.UserRepositoryPermissions[0].Repository, "maven-releases-"+suffix; got != want { + t.Fatalf("resolved permission repository = %q, want %q", got, want) + } +} + +func TestResolveNamesInvalidMode(t *testing.T) { + cfg := &Config{NameMode: "invalid"} + if _, err := cfg.ResolveNamesWithSuffix("20260324112233"); err == nil { + t.Fatal("ResolveNamesWithSuffix() expected error, got nil") + } +} + +// TestResolveNamesWithSuffixRequiresNonEmptySuffix verifies suffix mode rejects an empty suffix. +func TestResolveNamesWithSuffixRequiresNonEmptySuffix(t *testing.T) { + cfg := &Config{ + NameMode: "suffix", + Users: []User{ + {ID: "repo-admin"}, + }, + } + + _, err := cfg.ResolveNamesWithSuffix("") + if err == nil { + t.Fatal("ResolveNamesWithSuffix() expected error for empty suffix, got nil") + } + if !strings.Contains(err.Error(), "nameMode suffix requires a non-empty suffix") { + t.Fatalf("ResolveNamesWithSuffix() error = %v, want contains %q", err, "nameMode suffix requires a non-empty suffix") + } +} + +// TestEmailWithSuffixWithoutAt verifies local fallback when email text has no '@'. +func TestEmailWithSuffixWithoutAt(t *testing.T) { + const suffix = "20260324112233" + + got := emailWithSuffix("repo-admin", suffix) + want := "repo-admin-" + suffix + if got != want { + t.Fatalf("emailWithSuffix() = %q, want %q", got, want) + } +} + +func TestResolveNamesGenerateSuffix(t *testing.T) { + cfg := &Config{ + NameMode: "suffix", + Users: []User{ + {ID: "repo-admin"}, + }, + } + + resolved, suffix, err := cfg.ResolveNames() + if err != nil { + t.Fatalf("ResolveNames() error = %v", err) + } + if suffix == "" { + t.Fatal("ResolveNames() suffix should not be empty in suffix mode") + } + + matched, err := regexp.MatchString(`^[0-9]{14}$`, suffix) + if err != nil { + t.Fatalf("regexp.MatchString() error = %v", err) + } + if !matched { + t.Fatalf("ResolveNames() suffix format = %q, want 14-digit timestamp", suffix) + } + if !strings.HasSuffix(resolved.Users[0].ID, "-"+suffix) { + t.Fatalf("resolved user id = %q should end with -%s", resolved.Users[0].ID, suffix) + } +} + +// TestCloneConfigDeepCopyMutableNestedFields ensures cloneConfig does not share mutable nested state. +func TestCloneConfigDeepCopyMutableNestedFields(t *testing.T) { + cfg := &Config{ + Users: []User{ + {ID: "repo-admin", Roles: []string{"repository-manager"}}, + }, + Repositories: []Repository{ + { + Name: "maven-releases", + Proxy: &ProxyConfig{ + RemoteURL: "https://example.com", + Authentication: &AuthConfig{ + Type: "username", + Username: "proxy-user", + Password: "proxy-pass", + }, + }, + Maven: &MavenConfig{ + VersionPolicy: "RELEASE", + LayoutPolicy: "STRICT", + }, + Docker: &DockerConfig{ + HTTPPort: 18080, + ForceBasicAuth: true, + }, + Apt: &AptConfig{ + Distribution: "stable", + }, + Cleanup: &CleanupConfig{ + PolicyNames: []string{"cleanup-old"}, + }, + }, + }, + Privileges: []Privilege{ + {Name: "maven-deploy", Actions: []string{"READ"}}, + }, + Roles: []Role{ + {ID: "repository-manager", Privileges: []string{"maven-deploy"}, Roles: []string{"nx-admin"}}, + }, + UserRepositoryPermissions: []UserRepositoryPermission{ + {UserID: "repo-admin", Repository: "maven-releases", Privileges: []string{"READ"}}, + }, + } + + cloned := cloneConfig(cfg) + cloned.Users[0].Roles[0] = "changed-role" + cloned.Repositories[0].Proxy.RemoteURL = "https://changed.example.com" + cloned.Repositories[0].Proxy.Authentication.Username = "changed-proxy-user" + cloned.Repositories[0].Maven.VersionPolicy = "MIXED" + cloned.Repositories[0].Docker.HTTPPort = 28080 + cloned.Repositories[0].Apt.Distribution = "testing" + cloned.Repositories[0].Cleanup.PolicyNames[0] = "changed-policy" + cloned.Privileges[0].Actions[0] = "EDIT" + cloned.Roles[0].Privileges[0] = "changed-privilege" + cloned.Roles[0].Roles[0] = "changed-parent-role" + cloned.UserRepositoryPermissions[0].Privileges[0] = "BROWSE" + + if cfg.Users[0].Roles[0] != "repository-manager" { + t.Fatalf("source users roles mutated: got %q", cfg.Users[0].Roles[0]) + } + if cfg.Repositories[0].Proxy.RemoteURL != "https://example.com" { + t.Fatalf("source repository proxy remoteUrl mutated: got %q", cfg.Repositories[0].Proxy.RemoteURL) + } + if cfg.Repositories[0].Proxy.Authentication.Username != "proxy-user" { + t.Fatalf("source repository proxy auth username mutated: got %q", cfg.Repositories[0].Proxy.Authentication.Username) + } + if cfg.Repositories[0].Maven.VersionPolicy != "RELEASE" { + t.Fatalf("source repository maven versionPolicy mutated: got %q", cfg.Repositories[0].Maven.VersionPolicy) + } + if cfg.Repositories[0].Docker.HTTPPort != 18080 { + t.Fatalf("source repository docker httpPort mutated: got %d", cfg.Repositories[0].Docker.HTTPPort) + } + if cfg.Repositories[0].Apt.Distribution != "stable" { + t.Fatalf("source repository apt distribution mutated: got %q", cfg.Repositories[0].Apt.Distribution) + } + if cfg.Repositories[0].Cleanup.PolicyNames[0] != "cleanup-old" { + t.Fatalf("source repository cleanup policyNames mutated: got %q", cfg.Repositories[0].Cleanup.PolicyNames[0]) + } + if cfg.Privileges[0].Actions[0] != "READ" { + t.Fatalf("source privilege actions mutated: got %q", cfg.Privileges[0].Actions[0]) + } + if cfg.Roles[0].Privileges[0] != "maven-deploy" { + t.Fatalf("source role privileges mutated: got %q", cfg.Roles[0].Privileges[0]) + } + if cfg.Roles[0].Roles[0] != "nx-admin" { + t.Fatalf("source role parent roles mutated: got %q", cfg.Roles[0].Roles[0]) + } + if cfg.UserRepositoryPermissions[0].Privileges[0] != "READ" { + t.Fatalf("source user repository permissions mutated: got %q", cfg.UserRepositoryPermissions[0].Privileges[0]) + } +} + +// TestResolveNamesWithSuffixClearsNameModesForReuse ensures resolved config is idempotent for repeated create runs. +func TestResolveNamesWithSuffixClearsNameModesForReuse(t *testing.T) { + const firstSuffix = "20260324112233" + const secondSuffix = "20260324113344" + + cfg := &Config{ + NameMode: "suffix", + Users: []User{ + { + NameMode: "suffix", + ID: "repo-admin", + EmailAddress: "repo-admin@example.com", + Roles: []string{"repository-manager"}, + }, + }, + Repositories: []Repository{ + {NameMode: "suffix", Name: "maven-releases"}, + }, + Privileges: []Privilege{ + { + NameMode: "suffix", + Name: "maven-deploy", + Repository: "maven-releases", + Actions: []string{"READ"}, + }, + }, + Roles: []Role{ + { + NameMode: "suffix", + ID: "repository-manager", + Name: "Repository Manager", + Privileges: []string{"maven-deploy"}, + Description: "Role used by repository admins", + }, + }, + UserRepositoryPermissions: []UserRepositoryPermission{ + { + UserID: "repo-admin", + Repository: "maven-releases", + Privileges: []string{"READ"}, + }, + }, + } + + resolved, err := cfg.ResolveNamesWithSuffix(firstSuffix) + if err != nil { + t.Fatalf("ResolveNamesWithSuffix() first run error = %v", err) + } + + if resolved.NameMode != "" { + t.Fatalf("resolved config nameMode = %q, want empty", resolved.NameMode) + } + if resolved.Users[0].NameMode != "" { + t.Fatalf("resolved users[0].nameMode = %q, want empty", resolved.Users[0].NameMode) + } + if resolved.Repositories[0].NameMode != "" { + t.Fatalf("resolved repositories[0].nameMode = %q, want empty", resolved.Repositories[0].NameMode) + } + if resolved.Privileges[0].NameMode != "" { + t.Fatalf("resolved privileges[0].nameMode = %q, want empty", resolved.Privileges[0].NameMode) + } + if resolved.Roles[0].NameMode != "" { + t.Fatalf("resolved roles[0].nameMode = %q, want empty", resolved.Roles[0].NameMode) + } + + rerun, err := resolved.ResolveNamesWithSuffix(secondSuffix) + if err != nil { + t.Fatalf("ResolveNamesWithSuffix() second run error = %v", err) + } + + if !reflect.DeepEqual(rerun, resolved) { + t.Fatalf("resolved config should be reusable without additional suffixing.\nfirst=%+v\nsecond=%+v", resolved, rerun) + } +} diff --git a/pkg/config/types.go b/pkg/config/types.go index 4500fa8..2631571 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -3,6 +3,9 @@ package config // Config 主配置结构 type Config struct { + // NameMode controls default naming behavior for all resources. + // "name" keeps names unchanged; "suffix" appends a generated timestamp suffix. + NameMode string `yaml:"nameMode,omitempty"` Users []User `yaml:"users"` Repositories []Repository `yaml:"repositories"` Privileges []Privilege `yaml:"privileges"` @@ -12,6 +15,8 @@ type Config struct { // User 用户配置 type User struct { + // NameMode overrides Config.NameMode for this user. + NameMode string `yaml:"nameMode,omitempty"` ID string `yaml:"id"` FirstName string `yaml:"firstName"` LastName string `yaml:"lastName"` @@ -23,16 +28,18 @@ type User struct { // Repository 仓库配置 type Repository struct { - Name string `yaml:"name"` - Format string `yaml:"format"` - Type string `yaml:"type"` - Online bool `yaml:"online"` - Storage StorageConfig `yaml:"storage"` - Proxy *ProxyConfig `yaml:"proxy,omitempty"` - Maven *MavenConfig `yaml:"maven,omitempty"` - Docker *DockerConfig `yaml:"docker,omitempty"` - Apt *AptConfig `yaml:"apt,omitempty"` - Cleanup *CleanupConfig `yaml:"cleanup,omitempty"` + // NameMode overrides Config.NameMode for this repository. + NameMode string `yaml:"nameMode,omitempty"` + Name string `yaml:"name"` + Format string `yaml:"format"` + Type string `yaml:"type"` + Online bool `yaml:"online"` + Storage StorageConfig `yaml:"storage"` + Proxy *ProxyConfig `yaml:"proxy,omitempty"` + Maven *MavenConfig `yaml:"maven,omitempty"` + Docker *DockerConfig `yaml:"docker,omitempty"` + Apt *AptConfig `yaml:"apt,omitempty"` + Cleanup *CleanupConfig `yaml:"cleanup,omitempty"` } // StorageConfig 存储配置 @@ -87,6 +94,8 @@ type CleanupConfig struct { // Privilege 权限配置 type Privilege struct { + // NameMode overrides Config.NameMode for this privilege. + NameMode string `yaml:"nameMode,omitempty"` Name string `yaml:"name"` Description string `yaml:"description"` Type string `yaml:"type"` @@ -97,6 +106,8 @@ type Privilege struct { // Role 角色配置 type Role struct { + // NameMode overrides Config.NameMode for this role. + NameMode string `yaml:"nameMode,omitempty"` ID string `yaml:"id"` Name string `yaml:"name"` Description string `yaml:"description"`