From 4c8dfd7e206241815846dd043582960fa47506f4 Mon Sep 17 00:00:00 2001
From: Oleksii Kliukin <oleksii.kliukin@zalando.de>
Date: Thu, 3 May 2018 10:21:37 +0200
Subject: [PATCH] Remove the check for the clone cluster name. (#270)

* Sanity checks for the cluster name, improve tests.

- check that the normal and clone cluster name complies with the valid
  service name. For clone cluster, only do it if clone timestamp is not
  set; with a clone timestamp set, the clone name points to the S3 bucket

 - add tests and improve existing ones, making sure we don't call Error()
   method for an empty error, as well as that we don't miss cases where
   expected error is not empty, but actual call to be tested does not
   return an error.

Code review by @zerg-junior and @Jan-M
---
 pkg/spec/postgresql.go      | 53 ++++++++++++++++------
 pkg/spec/postgresql_test.go | 88 ++++++++++++++++++++++++++-----------
 2 files changed, 102 insertions(+), 39 deletions(-)

diff --git a/pkg/spec/postgresql.go b/pkg/spec/postgresql.go
index 7dac7992..c7b5df90 100644
--- a/pkg/spec/postgresql.go
+++ b/pkg/spec/postgresql.go
@@ -3,6 +3,7 @@ package spec
 import (
 	"encoding/json"
 	"fmt"
+	"regexp"
 	"strings"
 	"time"
 
@@ -76,6 +77,12 @@ const (
 	ClusterStatusInvalid      PostgresStatus = "Invalid"
 )
 
+const (
+	serviceNameMaxLength   = 63
+	clusterNameMaxLength   = serviceNameMaxLength - len("-repl")
+	serviceNameRegexString = `^[a-z]([-a-z0-9]*[a-z0-9])?$`
+)
+
 // Postgresql defines PostgreSQL Custom Resource Definition Object.
 type Postgresql struct {
 	metav1.TypeMeta   `json:",inline"`
@@ -126,7 +133,10 @@ type PostgresqlList struct {
 	Items []Postgresql `json:"items"`
 }
 
-var weekdays = map[string]int{"Sun": 0, "Mon": 1, "Tue": 2, "Wed": 3, "Thu": 4, "Fri": 5, "Sat": 6}
+var (
+	weekdays         = map[string]int{"Sun": 0, "Mon": 1, "Tue": 2, "Wed": 3, "Thu": 4, "Fri": 5, "Sat": 6}
+	serviceNameRegex = regexp.MustCompile(serviceNameRegexString)
+)
 
 func parseTime(s string) (time.Time, error) {
 	parts := strings.Split(s, ":")
@@ -225,10 +235,31 @@ func extractClusterName(clusterName string, teamName string) (string, error) {
 	if strings.ToLower(clusterName[:teamNameLen+1]) != strings.ToLower(teamName)+"-" {
 		return "", fmt.Errorf("name must match {TEAM}-{NAME} format")
 	}
+	if len(clusterName) > clusterNameMaxLength {
+		return "", fmt.Errorf("name cannot be longer than %d characters", clusterNameMaxLength)
+	}
+	if !serviceNameRegex.MatchString(clusterName) {
+		return "", fmt.Errorf("name must confirm to DNS-1035, regex used for validation is %q",
+			serviceNameRegexString)
+	}
 
 	return clusterName[teamNameLen+1:], nil
 }
 
+func validateCloneClusterDescription(clone *CloneDescription) error {
+	// when cloning from the basebackup (no end timestamp) check that the cluster name is a valid service name
+	if clone.ClusterName != "" && clone.EndTimestamp == "" {
+		if !serviceNameRegex.MatchString(clone.ClusterName) {
+			return fmt.Errorf("clone cluster name must confirm to DNS-1035, regex used for validation is %q",
+				serviceNameRegexString)
+		}
+		if len(clone.ClusterName) > serviceNameMaxLength {
+			return fmt.Errorf("clone cluster name must be no longer than %d characters", serviceNameMaxLength)
+		}
+	}
+	return nil
+}
+
 type postgresqlListCopy PostgresqlList
 type postgresqlCopy Postgresql
 
@@ -252,22 +283,16 @@ func (p *Postgresql) UnmarshalJSON(data []byte) error {
 	}
 	tmp2 := Postgresql(tmp)
 
-	clusterName, err := extractClusterName(tmp2.ObjectMeta.Name, tmp2.Spec.TeamID)
-	if err == nil {
-		tmp2.Spec.ClusterName = clusterName
-	} else {
+	if clusterName, err := extractClusterName(tmp2.ObjectMeta.Name, tmp2.Spec.TeamID); err != nil {
 		tmp2.Error = err
 		tmp2.Status = ClusterStatusInvalid
+	} else if err := validateCloneClusterDescription(&tmp2.Spec.Clone); err != nil {
+		tmp2.Error = err
+		tmp2.Status = ClusterStatusInvalid
+	} else {
+		tmp2.Spec.ClusterName = clusterName
 	}
-	// The assumption below is that a cluster to clone, if any, belongs to the same team
-	if tmp2.Spec.Clone.ClusterName != "" {
-		_, err := extractClusterName(tmp2.Spec.Clone.ClusterName, tmp2.Spec.TeamID)
-		if err != nil {
-			tmp2.Error = fmt.Errorf("%s for the cluster to clone", err)
-			tmp2.Spec.Clone = CloneDescription{}
-			tmp2.Status = ClusterStatusInvalid
-		}
-	}
+
 	*p = tmp2
 
 	return nil
diff --git a/pkg/spec/postgresql_test.go b/pkg/spec/postgresql_test.go
index 091334e8..07251ede 100644
--- a/pkg/spec/postgresql_test.go
+++ b/pkg/spec/postgresql_test.go
@@ -43,7 +43,10 @@ var clusterNames = []struct {
 	{"acid-test", "acid", "test", nil},
 	{"test-my-name", "test", "my-name", nil},
 	{"my-team-another-test", "my-team", "another-test", nil},
-	{"------strange-team-cluster", "-----", "strange-team-cluster", nil},
+	{"------strange-team-cluster", "-----", "strange-team-cluster",
+		errors.New(`name must confirm to DNS-1035, regex used for validation is "^[a-z]([-a-z0-9]*[a-z0-9])?$"`)},
+	{"fooobar-fooobarfooobarfooobarfooobarfooobarfooobarfooobarfooobar", "fooobar", "",
+		errors.New("name cannot be longer than 58 characters")},
 	{"acid-test", "test", "", errors.New("name must match {TEAM}-{NAME} format")},
 	{"-test", "", "", errors.New("team name is empty")},
 	{"-test", "-", "", errors.New("name must match {TEAM}-{NAME} format")},
@@ -51,6 +54,18 @@ var clusterNames = []struct {
 	{"-", "-", "", errors.New("name is too short")},
 }
 
+var cloneClusterDescriptions = []struct {
+	in  *CloneDescription
+	err error
+}{
+	{&CloneDescription{"foo+bar", "", "NotEmpty"}, nil},
+	{&CloneDescription{"foo+bar", "", ""},
+		errors.New(`clone cluster name must confirm to DNS-1035, regex used for validation is "^[a-z]([-a-z0-9]*[a-z0-9])?$"`)},
+	{&CloneDescription{"foobar123456789012345678901234567890123456789012345678901234567890", "", ""},
+		errors.New("clone cluster name must be no longer than 63 characters")},
+	{&CloneDescription{"foobar", "", ""}, nil},
+}
+
 var maintenanceWindows = []struct {
 	in  []byte
 	out MaintenanceWindow
@@ -279,14 +294,15 @@ var unmarshalCluster = []struct {
 				Name: "acid-testcluster1",
 			},
 			Spec: PostgresSpec{
-				TeamID:      "acid",
-				Clone:       CloneDescription{},
+				TeamID: "acid",
+				Clone: CloneDescription{
+					ClusterName: "team-batman",
+				},
 				ClusterName: "testcluster1",
 			},
-			Status: ClusterStatusInvalid,
-			Error:  errors.New("name must match {TEAM}-{NAME} format for the cluster to clone"),
+			Error: nil,
 		},
-		marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0},"resources":{"requests":{"cpu":"","memory":""},"limits":{"cpu":"","memory":""}},"teamId":"acid","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":{}},"status":"Invalid"}`), err: nil},
+		marshal: []byte(`{"kind":"Postgresql","apiVersion":"acid.zalan.do/v1","metadata":{"name":"acid-testcluster1","creationTimestamp":null},"spec":{"postgresql":{"version":"","parameters":null},"volume":{"size":"","storageClass":""},"patroni":{"initdb":null,"pg_hba":null,"ttl":0,"loop_wait":0,"retry_timeout":0,"maximum_lag_on_failover":0},"resources":{"requests":{"cpu":"","memory":""},"limits":{"cpu":"","memory":""}},"teamId":"acid","allowedSourceRanges":null,"numberOfInstances":0,"users":null,"clone":{"cluster":"team-batman"}}}`), err: nil},
 	{[]byte(`{"kind": "Postgresql","apiVersion": "acid.zalan.do/v1"`),
 		Postgresql{},
 		[]byte{},
@@ -350,11 +366,12 @@ func TestParseTime(t *testing.T) {
 	for _, tt := range parseTimeTests {
 		aTime, err := parseTime(tt.in)
 		if err != nil {
-			if err.Error() != tt.err.Error() {
+			if tt.err == nil || err.Error() != tt.err.Error() {
 				t.Errorf("ParseTime expected error: %v, got: %v", tt.err, err)
 			}
-
 			continue
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
 		}
 
 		if aTime != tt.out {
@@ -367,11 +384,12 @@ func TestWeekdayTime(t *testing.T) {
 	for _, tt := range parseWeekdayTests {
 		aTime, err := parseWeekday(tt.in)
 		if err != nil {
-			if err.Error() != tt.err.Error() {
+			if tt.err == nil || err.Error() != tt.err.Error() {
 				t.Errorf("ParseWeekday expected error: %v, got: %v", tt.err, err)
 			}
-
 			continue
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
 		}
 
 		if aTime != tt.out {
@@ -383,9 +401,13 @@ func TestWeekdayTime(t *testing.T) {
 func TestClusterName(t *testing.T) {
 	for _, tt := range clusterNames {
 		name, err := extractClusterName(tt.in, tt.inTeam)
-		if err != nil && err.Error() != tt.err.Error() {
-			t.Errorf("extractClusterName expected error: %v, got: %v", tt.err, err)
+		if err != nil {
+			if tt.err == nil || err.Error() != tt.err.Error() {
+				t.Errorf("extractClusterName expected error: %v, got: %v", tt.err, err)
+			}
 			continue
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
 		}
 		if name != tt.clusterName {
 			t.Errorf("Expected cluserName: %q, got: %q", tt.clusterName, name)
@@ -393,17 +415,29 @@ func TestClusterName(t *testing.T) {
 	}
 }
 
+func TestCloneClusterDescription(t *testing.T) {
+	for _, tt := range cloneClusterDescriptions {
+		if err := validateCloneClusterDescription(tt.in); err != nil {
+			if tt.err == nil || err.Error() != tt.err.Error() {
+				t.Errorf("testCloneClusterDescription expected error: %v, got: %v", tt.err, err)
+			}
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
+		}
+	}
+}
+
 func TestUnmarshalMaintenanceWindow(t *testing.T) {
 	for _, tt := range maintenanceWindows {
 		var m MaintenanceWindow
 		err := m.UnmarshalJSON(tt.in)
-		if err != nil && err.Error() != tt.err.Error() {
-			t.Errorf("MaintenanceWindow unmarshal expected error: %v, got %v", tt.err, err)
-			continue
-		}
-		if tt.err != nil && err == nil {
-			t.Errorf("Expected error")
+		if err != nil {
+			if tt.err == nil || err.Error() != tt.err.Error() {
+				t.Errorf("MaintenanceWindow unmarshal expected error: %v, got %v", tt.err, err)
+			}
 			continue
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
 		}
 
 		if !reflect.DeepEqual(m, tt.out) {
@@ -421,7 +455,6 @@ func TestMarshalMaintenanceWindow(t *testing.T) {
 		s, err := tt.out.MarshalJSON()
 		if err != nil {
 			t.Errorf("Marshal Error: %v", err)
-			continue
 		}
 
 		if !bytes.Equal(s, tt.in) {
@@ -435,11 +468,12 @@ func TestPostgresUnmarshal(t *testing.T) {
 		var cluster Postgresql
 		err := cluster.UnmarshalJSON(tt.in)
 		if err != nil {
-			if err.Error() != tt.err.Error() {
+			if tt.err == nil || err.Error() != tt.err.Error() {
 				t.Errorf("Unmarshal expected error: %v, got: %v", tt.err, err)
 			}
-
 			continue
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
 		}
 
 		if !reflect.DeepEqual(cluster, tt.out) {
@@ -457,7 +491,6 @@ func TestMarshal(t *testing.T) {
 		m, err := json.Marshal(tt.out)
 		if err != nil {
 			t.Errorf("Marshal error: %v", err)
-			continue
 		}
 		if !bytes.Equal(m, tt.marshal) {
 			t.Errorf("Marshal Postgresql expected: %q, got: %q", string(tt.marshal), string(m))
@@ -481,10 +514,15 @@ func TestUnmarshalPostgresList(t *testing.T) {
 	for _, tt := range postgresqlList {
 		var list PostgresqlList
 		err := list.UnmarshalJSON(tt.in)
-		if err != nil && err.Error() != tt.err.Error() {
-			t.Errorf("PostgresqlList unmarshal expected error: %v, got: %v", tt.err, err)
-			return
+		if err != nil {
+			if tt.err == nil || err.Error() != tt.err.Error() {
+				t.Errorf("PostgresqlList unmarshal expected error: %v, got: %v", tt.err, err)
+			}
+			continue
+		} else if tt.err != nil {
+			t.Errorf("Expected error: %v", tt.err)
 		}
+
 		if !reflect.DeepEqual(list, tt.out) {
 			t.Errorf("Postgresql list unmarshall expected: %#v, got: %#v", tt.out, list)
 		}
-- 
GitLab