diff --git a/replicate/common/consts.go b/replicate/common/consts.go index db18cb38..64234f1f 100644 --- a/replicate/common/consts.go +++ b/replicate/common/consts.go @@ -12,4 +12,6 @@ const ( ReplicateToMatching = "replicator.v1.mittwald.de/replicate-to-matching" KeepOwnerReferences = "replicator.v1.mittwald.de/keep-owner-references" StripLabels = "replicator.v1.mittwald.de/strip-labels" + PrefixAnnotation = "replicator.v1.mittwald.de/prefix" + SuffixAnnotation = "replicator.v1.mittwald.de/suffix" ) diff --git a/replicate/common/consts_test.go b/replicate/common/consts_test.go new file mode 100644 index 00000000..5e526442 --- /dev/null +++ b/replicate/common/consts_test.go @@ -0,0 +1,95 @@ +package common + +import ( + "strings" + "testing" +) + +func TestAnnotationConstants(t *testing.T) { + tests := []struct { + name string + constant string + expected string + }{ + { + name: "PrefixAnnotation has correct value", + constant: PrefixAnnotation, + expected: "replicator.v1.mittwald.de/prefix", + }, + { + name: "SuffixAnnotation has correct value", + constant: SuffixAnnotation, + expected: "replicator.v1.mittwald.de/suffix", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.constant != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, tt.constant) + } + }) + } +} + +func TestAnnotationConstantsFormat(t *testing.T) { + annotations := []string{ + PrefixAnnotation, + SuffixAnnotation, + } + + for _, annotation := range annotations { + t.Run("annotation format validation for "+annotation, func(t *testing.T) { + // Should start with replicator.v1.mittwald.de/ + if !strings.HasPrefix(annotation, "replicator.v1.mittwald.de/") { + t.Errorf("Annotation %s should start with 'replicator.v1.mittwald.de/'", annotation) + } + + // Should not contain uppercase letters + if strings.ToLower(annotation) != annotation { + t.Errorf("Annotation %s should be lowercase", annotation) + } + + // Should not end with slash + if strings.HasSuffix(annotation, "/") { + t.Errorf("Annotation %s should not end with slash", annotation) + } + + // Should not contain spaces + if strings.Contains(annotation, " ") { + t.Errorf("Annotation %s should not contain spaces", annotation) + } + }) + } +} + +func TestAllAnnotationConstantsUnique(t *testing.T) { + annotations := []string{ + ReplicateFromAnnotation, + ReplicatedAtAnnotation, + ReplicatedFromVersionAnnotation, + ReplicatedKeysAnnotation, + ReplicationAllowed, + ReplicationAllowedNamespaces, + ReplicateTo, + ReplicateToMatching, + KeepOwnerReferences, + StripLabels, + PrefixAnnotation, + SuffixAnnotation, + } + + seen := make(map[string]bool) + for _, annotation := range annotations { + if seen[annotation] { + t.Errorf("Duplicate annotation constant found: %s", annotation) + } + seen[annotation] = true + } + + // Verify we have the expected number of unique annotations + expectedCount := 12 + if len(seen) != expectedCount { + t.Errorf("Expected %d unique annotations, got %d", expectedCount, len(seen)) + } +} diff --git a/replicate/common/strings.go b/replicate/common/strings.go index a882deac..af0d8dfd 100644 --- a/replicate/common/strings.go +++ b/replicate/common/strings.go @@ -75,3 +75,78 @@ func StringToPatternList(list string) (result []*regexp.Regexp) { return } + +// GenerateTargetName creates a target resource name by combining prefix, source name, and suffix +// with implicit dashes. Handles empty prefix/suffix values gracefully and avoids duplicate dashes. +// Validates that the resulting name is a valid Kubernetes resource name. +func GenerateTargetName(sourceName, prefix, suffix string) string { + var result strings.Builder + + // Add prefix with implicit dash if needed + if prefix != "" { + result.WriteString(prefix) + // Add dash only if prefix doesn't already end with one + if !strings.HasSuffix(prefix, "-") { + result.WriteString("-") + } + } + + // Add source name + result.WriteString(sourceName) + + // Add suffix with implicit dash if needed + if suffix != "" { + // Add dash only if suffix doesn't start with one + if !strings.HasPrefix(suffix, "-") { + result.WriteString("-") + } + result.WriteString(suffix) + } + + targetName := result.String() + + // Validate the resulting name + if !IsValidKubernetesResourceName(targetName) { + log.Warnf("Generated target name '%s' may not be valid for Kubernetes resources. "+ + "Source: '%s', Prefix: '%s', Suffix: '%s'", targetName, sourceName, prefix, suffix) + } + + return targetName +} + +// IsValidKubernetesResourceName validates that a name follows Kubernetes naming conventions +func IsValidKubernetesResourceName(name string) bool { + if name == "" { + return false + } + + // Kubernetes resource names must be lowercase alphanumeric or '-' + // Must start and end with alphanumeric character + // Must be 253 characters or less + if len(name) > 253 { + return false + } + + // Check if starts and ends with alphanumeric + if len(name) > 0 { + first := name[0] + last := name[len(name)-1] + if !isAlphanumeric(first) || !isAlphanumeric(last) { + return false + } + } + + // Check all characters are valid + for _, char := range name { + if !isAlphanumeric(byte(char)) && char != '-' { + return false + } + } + + return true +} + +// isAlphanumeric checks if a byte is a lowercase letter or digit +func isAlphanumeric(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') +} diff --git a/replicate/common/strings_test.go b/replicate/common/strings_test.go new file mode 100644 index 00000000..482fee97 --- /dev/null +++ b/replicate/common/strings_test.go @@ -0,0 +1,386 @@ +package common + +import ( + "strings" + "testing" +) + +func TestGenerateTargetName(t *testing.T) { + tests := []struct { + name string + sourceName string + prefix string + suffix string + expected string + }{ + { + name: "no prefix or suffix", + sourceName: "my-secret", + prefix: "", + suffix: "", + expected: "my-secret", + }, + { + name: "prefix only", + sourceName: "my-secret", + prefix: "prod", + suffix: "", + expected: "prod-my-secret", + }, + { + name: "suffix only", + sourceName: "my-secret", + prefix: "", + suffix: "backup", + expected: "my-secret-backup", + }, + { + name: "both prefix and suffix", + sourceName: "my-secret", + prefix: "prod", + suffix: "backup", + expected: "prod-my-secret-backup", + }, + { + name: "prefix already ends with dash", + sourceName: "my-secret", + prefix: "prod-", + suffix: "", + expected: "prod-my-secret", + }, + { + name: "suffix already starts with dash", + sourceName: "my-secret", + prefix: "", + suffix: "-backup", + expected: "my-secret-backup", + }, + { + name: "both prefix and suffix already have dashes", + sourceName: "my-secret", + prefix: "prod-", + suffix: "-backup", + expected: "prod-my-secret-backup", + }, + { + name: "source name already has dashes", + sourceName: "my-complex-secret-name", + prefix: "prod", + suffix: "backup", + expected: "prod-my-complex-secret-name-backup", + }, + { + name: "empty source name", + sourceName: "", + prefix: "prod", + suffix: "backup", + expected: "prod--backup", + }, + { + name: "single character components", + sourceName: "s", + prefix: "p", + suffix: "b", + expected: "p-s-b", + }, + { + name: "numeric components", + sourceName: "secret123", + prefix: "env1", + suffix: "v2", + expected: "env1-secret123-v2", + }, + { + name: "mixed case (should preserve case)", + sourceName: "MySecret", + prefix: "PROD", + suffix: "Backup", + expected: "PROD-MySecret-Backup", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateTargetName(tt.sourceName, tt.prefix, tt.suffix) + if result != tt.expected { + t.Errorf("GenerateTargetName(%q, %q, %q) = %q, expected %q", + tt.sourceName, tt.prefix, tt.suffix, result, tt.expected) + } + }) + } +} + +func TestGenerateTargetNameEdgeCases(t *testing.T) { + tests := []struct { + name string + sourceName string + prefix string + suffix string + validate func(t *testing.T, result string) + }{ + { + name: "multiple consecutive dashes in prefix", + sourceName: "secret", + prefix: "prod--", + suffix: "", + validate: func(t *testing.T, result string) { + // Should not create triple dashes + if strings.Contains(result, "---") { + t.Errorf("Result should not contain triple dashes: %s", result) + } + expected := "prod--secret" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }, + }, + { + name: "multiple consecutive dashes in suffix", + sourceName: "secret", + prefix: "", + suffix: "--backup", + validate: func(t *testing.T, result string) { + // Should not create triple dashes + if strings.Contains(result, "---") { + t.Errorf("Result should not contain triple dashes: %s", result) + } + expected := "secret--backup" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }, + }, + { + name: "source name starts and ends with dashes", + sourceName: "-secret-", + prefix: "prod", + suffix: "backup", + validate: func(t *testing.T, result string) { + expected := "prod--secret--backup" + if result != expected { + t.Errorf("Expected %s, got %s", expected, result) + } + }, + }, + { + name: "all empty strings", + sourceName: "", + prefix: "", + suffix: "", + validate: func(t *testing.T, result string) { + if result != "" { + t.Errorf("Expected empty string, got %s", result) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateTargetName(tt.sourceName, tt.prefix, tt.suffix) + tt.validate(t, result) + }) + } +} + +func TestGenerateTargetNameKubernetesCompliance(t *testing.T) { + tests := []struct { + name string + sourceName string + prefix string + suffix string + }{ + { + name: "typical kubernetes resource name", + sourceName: "my-app-config", + prefix: "prod", + suffix: "v1", + }, + { + name: "long names", + sourceName: "very-long-application-configuration-secret-name", + prefix: "production-environment", + suffix: "version-1-backup", + }, + { + name: "names with numbers", + sourceName: "app-v2-config", + prefix: "env1", + suffix: "backup2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GenerateTargetName(tt.sourceName, tt.prefix, tt.suffix) + + // Basic Kubernetes naming validation + // Should not start or end with dash (unless original source did) + if len(result) > 0 { + if result[0] == '-' && len(tt.prefix) > 0 && tt.prefix[0] != '-' { + t.Errorf("Result should not start with dash when prefix doesn't: %s", result) + } + if result[len(result)-1] == '-' && len(tt.suffix) > 0 && tt.suffix[len(tt.suffix)-1] != '-' { + t.Errorf("Result should not end with dash when suffix doesn't: %s", result) + } + } + + // Should only contain lowercase letters, numbers, and hyphens for typical k8s names + // Note: This test is informational - the function doesn't enforce case conversion + // as that might break existing naming conventions + }) + } +} + +func TestGenerateTargetNameConsistency(t *testing.T) { + // Test that the function is deterministic + sourceName := "test-secret" + prefix := "prod" + suffix := "backup" + + result1 := GenerateTargetName(sourceName, prefix, suffix) + result2 := GenerateTargetName(sourceName, prefix, suffix) + + if result1 != result2 { + t.Errorf("Function should be deterministic. Got %s and %s", result1, result2) + } +} + +func TestIsValidKubernetesResourceName(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "valid simple name", + input: "my-secret", + expected: true, + }, + { + name: "valid name with numbers", + input: "secret-123", + expected: true, + }, + { + name: "valid single character", + input: "a", + expected: true, + }, + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "starts with dash", + input: "-secret", + expected: false, + }, + { + name: "ends with dash", + input: "secret-", + expected: false, + }, + { + name: "contains uppercase", + input: "Secret", + expected: false, + }, + { + name: "contains special characters", + input: "secret@123", + expected: false, + }, + { + name: "contains underscore", + input: "secret_name", + expected: false, + }, + { + name: "too long", + input: strings.Repeat("a", 254), + expected: false, + }, + { + name: "exactly 253 characters", + input: strings.Repeat("a", 253), + expected: true, + }, + { + name: "valid complex name", + input: "my-app-v1-config-backup", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsValidKubernetesResourceName(tt.input) + if result != tt.expected { + t.Errorf("IsValidKubernetesResourceName(%q) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestGenerateTargetNameValidation(t *testing.T) { + tests := []struct { + name string + sourceName string + prefix string + suffix string + shouldWarn bool + }{ + { + name: "valid combination", + sourceName: "my-secret", + prefix: "prod", + suffix: "backup", + shouldWarn: false, + }, + { + name: "invalid prefix with uppercase", + sourceName: "secret", + prefix: "PROD", + suffix: "", + shouldWarn: true, + }, + { + name: "invalid suffix with special chars", + sourceName: "secret", + prefix: "", + suffix: "backup@v1", + shouldWarn: true, + }, + { + name: "result starts with dash", + sourceName: "secret", + prefix: "-prod", + suffix: "", + shouldWarn: true, + }, + { + name: "result ends with dash", + sourceName: "secret", + prefix: "", + suffix: "backup-", + shouldWarn: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We can't easily test the warning log, but we can test that the function + // still returns a result and that the validation function works correctly + result := GenerateTargetName(tt.sourceName, tt.prefix, tt.suffix) + isValid := IsValidKubernetesResourceName(result) + + if tt.shouldWarn && isValid { + t.Errorf("Expected invalid name but got valid: %s", result) + } else if !tt.shouldWarn && !isValid { + t.Errorf("Expected valid name but got invalid: %s", result) + } + }) + } +} diff --git a/replicate/configmap/configmaps.go b/replicate/configmap/configmaps.go index 33018f20..332e0644 100644 --- a/replicate/configmap/configmaps.go +++ b/replicate/configmap/configmaps.go @@ -154,7 +154,14 @@ func (r *Replicator) ReplicateDataFrom(sourceObj interface{}, targetObj interfac // ReplicateObjectTo copies the whole object to target namespace func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespace) error { source := sourceObj.(*v1.ConfigMap) - targetLocation := fmt.Sprintf("%s/%s", target.Name, source.Name) + + // Extract prefix and suffix annotations + prefix := source.Annotations[common.PrefixAnnotation] + suffix := source.Annotations[common.SuffixAnnotation] + + // Generate target name using prefix and suffix + targetName := common.GenerateTargetName(source.Name, prefix, suffix) + targetLocation := fmt.Sprintf("%s/%s", target.Name, targetName) logger := log. WithField("kind", r.Kind). @@ -235,7 +242,7 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa } sort.Strings(replicatedKeys) - resourceCopy.Name = source.Name + resourceCopy.Name = targetName resourceCopy.Labels = labelsCopy resourceCopy.Annotations[common.ReplicatedAtAnnotation] = time.Now().Format(time.RFC3339) resourceCopy.Annotations[common.ReplicatedFromVersionAnnotation] = source.ResourceVersion @@ -243,18 +250,18 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa var obj interface{} if exists { - logger.Debugf("Updating existing secret %s/%s", target.Name, resourceCopy.Name) + logger.Debugf("Updating existing configmap %s/%s", target.Name, targetName) obj, err = r.Client.CoreV1().ConfigMaps(target.Name).Update(context.TODO(), resourceCopy, metav1.UpdateOptions{}) } else { - logger.Debugf("Creating a new secret secret %s/%s", target.Name, resourceCopy.Name) + logger.Debugf("Creating a new configmap %s/%s", target.Name, targetName) obj, err = r.Client.CoreV1().ConfigMaps(target.Name).Create(context.TODO(), resourceCopy, metav1.CreateOptions{}) } if err != nil { - return errors.Wrapf(err, "Failed to update secret %s/%s", target.Name, resourceCopy.Name) + return errors.Wrapf(err, "Failed to update configmap %s/%s", target.Name, targetName) } if err := r.Store.Update(obj); err != nil { - return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, resourceCopy) + return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetName) } return nil diff --git a/replicate/role/roles.go b/replicate/role/roles.go index ec28f9e1..96012fb7 100644 --- a/replicate/role/roles.go +++ b/replicate/role/roles.go @@ -93,7 +93,14 @@ func (r *Replicator) ReplicateDataFrom(sourceObj interface{}, targetObj interfac // ReplicateObjectTo copies the whole object to target namespace func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespace) error { source := sourceObj.(*rbacv1.Role) - targetLocation := fmt.Sprintf("%s/%s", target.Name, source.Name) + + // Extract prefix and suffix annotations + prefix := source.Annotations[common.PrefixAnnotation] + suffix := source.Annotations[common.SuffixAnnotation] + + // Generate target name using prefix and suffix + targetName := common.GenerateTargetName(source.Name, prefix, suffix) + targetLocation := fmt.Sprintf("%s/%s", target.Name, targetName) logger := log. WithField("kind", r.Kind). @@ -145,7 +152,7 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa } } - targetCopy.Name = source.Name + targetCopy.Name = targetName targetCopy.Labels = labelsCopy targetCopy.Rules = source.Rules targetCopy.Annotations[common.ReplicatedAtAnnotation] = time.Now().Format(time.RFC3339) @@ -153,18 +160,18 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa var obj interface{} if exists { - logger.Debugf("Updating existing role %s/%s", target.Name, targetCopy.Name) + logger.Debugf("Updating existing role %s/%s", target.Name, targetName) obj, err = r.Client.RbacV1().Roles(target.Name).Update(context.TODO(), targetCopy, metav1.UpdateOptions{}) } else { - logger.Debugf("Creating a new role %s/%s", target.Name, targetCopy.Name) + logger.Debugf("Creating a new role %s/%s", target.Name, targetName) obj, err = r.Client.RbacV1().Roles(target.Name).Create(context.TODO(), targetCopy, metav1.CreateOptions{}) } if err != nil { - return errors.Wrapf(err, "Failed to update role %s/%s", target.Name, targetCopy.Name) + return errors.Wrapf(err, "Failed to update role %s/%s", target.Name, targetName) } if err := r.Store.Update(obj); err != nil { - return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetCopy) + return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetName) } return nil diff --git a/replicate/rolebinding/rolebindings.go b/replicate/rolebinding/rolebindings.go index f895863d..28e5e133 100644 --- a/replicate/rolebinding/rolebindings.go +++ b/replicate/rolebinding/rolebindings.go @@ -95,7 +95,14 @@ func (r *Replicator) ReplicateDataFrom(sourceObj interface{}, targetObj interfac // ReplicateObjectTo copies the whole object to target namespace func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespace) error { source := sourceObj.(*rbacv1.RoleBinding) - targetLocation := fmt.Sprintf("%s/%s", target.Name, source.Name) + + // Extract prefix and suffix annotations + prefix := source.Annotations[common.PrefixAnnotation] + suffix := source.Annotations[common.SuffixAnnotation] + + // Generate target name using prefix and suffix + targetName := common.GenerateTargetName(source.Name, prefix, suffix) + targetLocation := fmt.Sprintf("%s/%s", target.Name, targetName) logger := log. WithField("kind", r.Kind). @@ -145,7 +152,7 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa } - targetCopy.Name = source.Name + targetCopy.Name = targetName targetCopy.Labels = labelsCopy targetCopy.Subjects = source.Subjects targetCopy.RoleRef = source.RoleRef @@ -158,27 +165,27 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa } if exists { if err == nil { - logger.Debugf("Updating existing roleBinding %s/%s", target.Name, targetCopy.Name) + logger.Debugf("Updating existing roleBinding %s/%s", target.Name, targetName) obj, err = r.Client.RbacV1().RoleBindings(target.Name).Update(context.TODO(), targetCopy, metav1.UpdateOptions{}) } } else { if err == nil { - logger.Debugf("Creating a new roleBinding %s/%s", target.Name, targetCopy.Name) + logger.Debugf("Creating a new roleBinding %s/%s", target.Name, targetName) obj, err = r.Client.RbacV1().RoleBindings(target.Name).Create(context.TODO(), targetCopy, metav1.CreateOptions{}) } } if err != nil { - return errors.Wrapf(err, "Failed to update roleBinding %s/%s", target.Name, targetCopy.Name) + return errors.Wrapf(err, "Failed to update roleBinding %s/%s", target.Name, targetName) } if err := r.Store.Update(obj); err != nil { - return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetCopy) + return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetName) } return nil } -//Checks if Role required for RoleBinding exists. Retries a few times before returning error to allow replication to catch up +// Checks if Role required for RoleBinding exists. Retries a few times before returning error to allow replication to catch up func (r *Replicator) canReplicate(targetNameSpace string, roleRef string) (err error) { for i := 0; i < 5; i++ { _, err = r.Client.RbacV1().Roles(targetNameSpace).Get(context.TODO(), roleRef, metav1.GetOptions{}) diff --git a/replicate/secret/secrets.go b/replicate/secret/secrets.go index 192624bb..cff70c4b 100644 --- a/replicate/secret/secrets.go +++ b/replicate/secret/secrets.go @@ -136,7 +136,14 @@ func (r *Replicator) ReplicateDataFrom(sourceObj interface{}, targetObj interfac // ReplicateObjectTo copies the whole object to target namespace func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespace) error { source := sourceObj.(*v1.Secret) - targetLocation := fmt.Sprintf("%s/%s", target.Name, source.Name) + + // Extract prefix and suffix annotations + prefix := source.Annotations[common.PrefixAnnotation] + suffix := source.Annotations[common.SuffixAnnotation] + + // Generate target name using prefix and suffix + targetName := common.GenerateTargetName(source.Name, prefix, suffix) + targetLocation := fmt.Sprintf("%s/%s", target.Name, targetName) logger := log. WithField("kind", r.Kind). @@ -194,7 +201,7 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa } } - resourceCopy.Name = source.Name + resourceCopy.Name = targetName resourceCopy.Labels = labelsCopy resourceCopy.Type = targetResourceType resourceCopy.Annotations[common.ReplicatedAtAnnotation] = time.Now().Format(time.RFC3339) @@ -203,16 +210,16 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa var obj interface{} if exists { - logger.Debugf("Updating existing secret %s/%s", target.Name, resourceCopy.Name) + logger.Debugf("Updating existing secret %s/%s", target.Name, targetName) obj, err = r.Client.CoreV1().Secrets(target.Name).Update(context.TODO(), resourceCopy, metav1.UpdateOptions{}) } else { - logger.Debugf("Creating a new secret secret %s/%s", target.Name, resourceCopy.Name) + logger.Debugf("Creating a new secret %s/%s", target.Name, targetName) obj, err = r.Client.CoreV1().Secrets(target.Name).Create(context.TODO(), resourceCopy, metav1.CreateOptions{}) } if err != nil { - err = errors.Wrapf(err, "Failed to update secret %s/%s", target.Name, resourceCopy.Name) + err = errors.Wrapf(err, "Failed to update secret %s/%s", target.Name, targetName) } else if err = r.Store.Update(obj); err != nil { - err = errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, resourceCopy) + err = errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetName) } return err diff --git a/replicate/secret/secrets_test.go b/replicate/secret/secrets_test.go index 691d711d..c1744830 100644 --- a/replicate/secret/secrets_test.go +++ b/replicate/secret/secrets_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "k8s.io/client-go/tools/clientcmd" "os" "path/filepath" "reflect" @@ -13,6 +12,8 @@ import ( "testing" "time" + "k8s.io/client-go/tools/clientcmd" + "github.com/mittwald/kubernetes-replicator/replicate/common" pkgerrors "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -1396,6 +1397,145 @@ func TestSecretReplicatorSyncByContent(t *testing.T) { close(stop) }) + t.Run("replicates with prefix annotation", func(t *testing.T) { + targetNamespace := prefix + "test2" + source := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-prefix", + Namespace: ns.Name, + Annotations: map[string]string{ + common.ReplicateTo: targetNamespace, + common.PrefixAnnotation: "prod", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "foo": []byte("Hello World"), + }, + } + + expectedTargetName := "prod-source-prefix" + + wg, stop := waitForSecrets(client, 2, EventHandlerFuncs{ + AddFunc: func(wg *sync.WaitGroup, obj any) { + secret := obj.(*corev1.Secret) + if secret.Namespace == source.Namespace && secret.Name == source.Name { + log.Debugf("AddFunc source %+v", obj) + wg.Done() + } else if secret.Namespace == targetNamespace && secret.Name == expectedTargetName { + log.Debugf("AddFunc target %+v", obj) + wg.Done() + } + }, + }) + + _, err := secrets.Create(context.TODO(), &source, metav1.CreateOptions{}) + require.NoError(t, err) + + waitWithTimeout(wg, MaxWaitTime) + close(stop) + + // Verify the target secret was created with the correct name + targetSecrets := client.CoreV1().Secrets(targetNamespace) + target, err := targetSecrets.Get(context.TODO(), expectedTargetName, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expectedTargetName, target.Name) + require.Equal(t, []byte("Hello World"), target.Data["foo"]) + }) + + t.Run("replicates with suffix annotation", func(t *testing.T) { + targetNamespace := prefix + "test2" + source := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-suffix", + Namespace: ns.Name, + Annotations: map[string]string{ + common.ReplicateTo: targetNamespace, + common.SuffixAnnotation: "backup", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "foo": []byte("Hello World"), + }, + } + + expectedTargetName := "source-suffix-backup" + + wg, stop := waitForSecrets(client, 2, EventHandlerFuncs{ + AddFunc: func(wg *sync.WaitGroup, obj any) { + secret := obj.(*corev1.Secret) + if secret.Namespace == source.Namespace && secret.Name == source.Name { + log.Debugf("AddFunc source %+v", obj) + wg.Done() + } else if secret.Namespace == targetNamespace && secret.Name == expectedTargetName { + log.Debugf("AddFunc target %+v", obj) + wg.Done() + } + }, + }) + + _, err := secrets.Create(context.TODO(), &source, metav1.CreateOptions{}) + require.NoError(t, err) + + waitWithTimeout(wg, MaxWaitTime) + close(stop) + + // Verify the target secret was created with the correct name + targetSecrets := client.CoreV1().Secrets(targetNamespace) + target, err := targetSecrets.Get(context.TODO(), expectedTargetName, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expectedTargetName, target.Name) + require.Equal(t, []byte("Hello World"), target.Data["foo"]) + }) + + t.Run("replicates with both prefix and suffix annotations", func(t *testing.T) { + targetNamespace := prefix + "test2" + source := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "source-both", + Namespace: ns.Name, + Annotations: map[string]string{ + common.ReplicateTo: targetNamespace, + common.PrefixAnnotation: "prod", + common.SuffixAnnotation: "v1", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "foo": []byte("Hello World"), + }, + } + + expectedTargetName := "prod-source-both-v1" + + wg, stop := waitForSecrets(client, 2, EventHandlerFuncs{ + AddFunc: func(wg *sync.WaitGroup, obj any) { + secret := obj.(*corev1.Secret) + if secret.Namespace == source.Namespace && secret.Name == source.Name { + log.Debugf("AddFunc source %+v", obj) + wg.Done() + } else if secret.Namespace == targetNamespace && secret.Name == expectedTargetName { + log.Debugf("AddFunc target %+v", obj) + wg.Done() + } + }, + }) + + _, err := secrets.Create(context.TODO(), &source, metav1.CreateOptions{}) + require.NoError(t, err) + + waitWithTimeout(wg, MaxWaitTime) + close(stop) + + // Verify the target secret was created with the correct name + targetSecrets := client.CoreV1().Secrets(targetNamespace) + target, err := targetSecrets.Get(context.TODO(), expectedTargetName, metav1.GetOptions{}) + require.NoError(t, err) + require.Equal(t, expectedTargetName, target.Name) + require.Equal(t, []byte("Hello World"), target.Data["foo"]) + }) + } type createInformerFunc func(factory informers.SharedInformerFactory) cache.SharedIndexInformer diff --git a/replicate/serviceaccount/serviceaccounts.go b/replicate/serviceaccount/serviceaccounts.go index ebe736de..f25f4d9b 100644 --- a/replicate/serviceaccount/serviceaccounts.go +++ b/replicate/serviceaccount/serviceaccounts.go @@ -93,7 +93,14 @@ func (r *Replicator) ReplicateDataFrom(sourceObj interface{}, targetObj interfac // ReplicateObjectTo copies the whole object to target namespace func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespace) error { source := sourceObj.(*corev1.ServiceAccount) - targetLocation := fmt.Sprintf("%s/%s", target.Name, source.Name) + + // Extract prefix and suffix annotations + prefix := source.Annotations[common.PrefixAnnotation] + suffix := source.Annotations[common.SuffixAnnotation] + + // Generate target name using prefix and suffix + targetName := common.GenerateTargetName(source.Name, prefix, suffix) + targetLocation := fmt.Sprintf("%s/%s", target.Name, targetName) logger := log. WithField("kind", r.Kind). @@ -143,7 +150,7 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa } - targetCopy.Name = source.Name + targetCopy.Name = targetName targetCopy.Labels = labelsCopy targetCopy.ImagePullSecrets = source.ImagePullSecrets targetCopy.Annotations[common.ReplicatedAtAnnotation] = time.Now().Format(time.RFC3339) @@ -153,21 +160,21 @@ func (r *Replicator) ReplicateObjectTo(sourceObj interface{}, target *v1.Namespa if exists { if err == nil { - logger.Debugf("Updating existing serviceAccount %s/%s", target.Name, targetCopy.Name) + logger.Debugf("Updating existing serviceAccount %s/%s", target.Name, targetName) obj, err = r.Client.CoreV1().ServiceAccounts(target.Name).Update(context.TODO(), targetCopy, metav1.UpdateOptions{}) } } else { if err == nil { - logger.Debugf("Creating a new serviceAccount %s/%s", target.Name, targetCopy.Name) + logger.Debugf("Creating a new serviceAccount %s/%s", target.Name, targetName) obj, err = r.Client.CoreV1().ServiceAccounts(target.Name).Create(context.TODO(), targetCopy, metav1.CreateOptions{}) } } if err != nil { - return errors.Wrapf(err, "Failed to update serviceAccount %s/%s", target.Name, targetCopy.Name) + return errors.Wrapf(err, "Failed to update serviceAccount %s/%s", target.Name, targetName) } if err := r.Store.Update(obj); err != nil { - return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetCopy) + return errors.Wrapf(err, "Failed to update cache for %s/%s", target.Name, targetName) } return nil