Repository: fluxcd/flagger
Pull request #879 (2021-04-08 08:47:25)

Implement SMI v1alpha2 router
This PR adds support for SMI TrafficSplit v1alpha2.

For service mesh providers that require this version set:

```yaml
apiVersion: flagger.app/v1beta1
kind: Canary
metadata:
  name: my-canary
spec:
  provider: "smi:v1alpha2"
```

Ref: #551
Ref: https://github.com/openservicemesh/osm/issues/1081

Note that both v1alpha3 and v1alpha4 are backwards compatible with TrafficSplit v1alpha2. For newer versions the v1alpha2 must be set in the CRD e.g.:

```yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: trafficsplits.split.smi-spec.io
spec:
  group: split.smi-spec.io
  scope: Namespaced
  names:
    kind: TrafficSplit
    listKind: TrafficSplitList
    shortNames:
    - ts
    plural: trafficsplits
    singular: trafficsplit
  versions:
    - name: v1alpha4
      served: true
      storage: true
    - name: v1alpha3
      served: false
      storage: false
    - name: v1alpha2
      served: false
      storage: false
```


Patch:
diff --git a/pkg/apis/smi/v1alpha2/traffic_split.go b/pkg/apis/smi/v1alpha2/traffic_split.go
index 440752c0..c9680e99 100644
--- a/pkg/apis/smi/v1alpha2/traffic_split.go
+++ b/pkg/apis/smi/v1alpha2/traffic_split.go
@@ -23,14 +23,6 @@ type TrafficSplit struct {
 	// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#spec-and-status
 	// +optional
 	Spec TrafficSplitSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`
-
-	// Most recently observed status of the pod.
-	// This data may not be up to date.
-	// Populated by the system.
-	// Read-only.
-	// More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#spec-and-status
-	// +optional
-	//Status Status `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
 }
 
 // TrafficSplitSpec is the specification for a TrafficSplit
diff --git a/pkg/router/factory.go b/pkg/router/factory.go
index a9f2fbc4..c315924c 100644
--- a/pkg/router/factory.go
+++ b/pkg/router/factory.go
@@ -104,8 +104,8 @@ func (factory *Factory) MeshRouter(provider string, labelSelector string) Interf
 			kubeClient:    factory.kubeClient,
 			istioClient:   factory.meshClient,
 		}
-	case strings.HasPrefix(provider, flaggerv1.SMIProvider):
-		mesh := strings.TrimPrefix(provider, flaggerv1.SMIProvider+":")
+	case strings.HasPrefix(provider, flaggerv1.SMIProvider+":v1alpha1"):
+		mesh := strings.TrimPrefix(provider, flaggerv1.SMIProvider+":v1alpha1:")
 		return &SmiRouter{
 			logger:        factory.logger,
 			flaggerClient: factory.flaggerClient,
@@ -113,6 +113,15 @@ func (factory *Factory) MeshRouter(provider string, labelSelector string) Interf
 			smiClient:     factory.meshClient,
 			targetMesh:    mesh,
 		}
+	case strings.HasPrefix(provider, flaggerv1.SMIProvider+":v1alpha2"):
+		mesh := strings.TrimPrefix(provider, flaggerv1.SMIProvider+":v1alpha2:")
+		return &Smiv1alpha2Router{
+			logger:        factory.logger,
+			flaggerClient: factory.flaggerClient,
+			kubeClient:    factory.kubeClient,
+			smiClient:     factory.meshClient,
+			targetMesh:    mesh,
+		}
 	case provider == flaggerv1.ContourProvider:
 		return &ContourRouter{
 			logger:        factory.logger,
diff --git a/pkg/router/smi_v1alpha2.go b/pkg/router/smi_v1alpha2.go
new file mode 100644
index 00000000..13b2220c
--- /dev/null
+++ b/pkg/router/smi_v1alpha2.go
@@ -0,0 +1,198 @@
+/*
+Copyright 2020 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package router
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"go.uber.org/zap"
+	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/kubernetes"
+
+	flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
+	smiv1alpha2 "github.com/fluxcd/flagger/pkg/apis/smi/v1alpha2"
+	clientset "github.com/fluxcd/flagger/pkg/client/clientset/versioned"
+)
+
+type Smiv1alpha2Router struct {
+	kubeClient    kubernetes.Interface
+	flaggerClient clientset.Interface
+	smiClient     clientset.Interface
+	logger        *zap.SugaredLogger
+	targetMesh    string
+}
+
+// Reconcile creates or updates the SMI traffic split
+func (sr *Smiv1alpha2Router) Reconcile(canary *flaggerv1.Canary) error {
+	apexName, primaryName, canaryName := canary.GetServiceNames()
+
+	var host string
+	if len(canary.Spec.Service.Hosts) > 0 {
+		host = canary.Spec.Service.Hosts[0]
+	} else {
+		host = apexName
+	}
+
+	tsSpec := smiv1alpha2.TrafficSplitSpec{
+		Service: host,
+		Backends: []smiv1alpha2.TrafficSplitBackend{
+			{
+				Service: canaryName,
+				Weight:  0,
+			},
+			{
+				Service: primaryName,
+				Weight:  100,
+			},
+		},
+	}
+
+	ts, err := sr.smiClient.SplitV1alpha2().TrafficSplits(canary.Namespace).Get(context.TODO(), apexName, metav1.GetOptions{})
+	// create traffic split
+	if errors.IsNotFound(err) {
+		t := &smiv1alpha2.TrafficSplit{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      apexName,
+				Namespace: canary.Namespace,
+				OwnerReferences: []metav1.OwnerReference{
+					*metav1.NewControllerRef(canary, schema.GroupVersionKind{
+						Group:   flaggerv1.SchemeGroupVersion.Group,
+						Version: flaggerv1.SchemeGroupVersion.Version,
+						Kind:    flaggerv1.CanaryKind,
+					}),
+				},
+				Annotations: sr.makeAnnotations(canary.Spec.Service.Gateways),
+			},
+			Spec: tsSpec,
+		}
+
+		_, err := sr.smiClient.SplitV1alpha2().TrafficSplits(canary.Namespace).Create(context.TODO(), t, metav1.CreateOptions{})
+		if err != nil {
+			return fmt.Errorf("TrafficSplit %s.%s create error: %w", apexName, canary.Namespace, err)
+		}
+
+		sr.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
+			Infof("TrafficSplit %s.%s created", t.GetName(), canary.Namespace)
+		return nil
+	} else if err != nil {
+		return fmt.Errorf("TrafficSplit %s.%s get query error: %w", apexName, canary.Namespace, err)
+	}
+
+	// update traffic split
+	if diff := cmp.Diff(tsSpec, ts.Spec, cmpopts.IgnoreFields(smiv1alpha2.TrafficSplitBackend{}, "Weight")); diff != "" {
+		tsClone := ts.DeepCopy()
+		tsClone.Spec = tsSpec
+
+		_, err := sr.smiClient.SplitV1alpha2().TrafficSplits(canary.Namespace).Update(context.TODO(), tsClone, metav1.UpdateOptions{})
+		if err != nil {
+			return fmt.Errorf("TrafficSplit %s.%s update error: %w", apexName, canary.Namespace, err)
+		}
+
+		sr.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
+			Infof("TrafficSplit %s.%s updated", apexName, canary.Namespace)
+		return nil
+	}
+
+	return nil
+}
+
+// GetRoutes returns the destinations weight for primary and canary
+func (sr *Smiv1alpha2Router) GetRoutes(canary *flaggerv1.Canary) (
+	primaryWeight int,
+	canaryWeight int,
+	mirrored bool,
+	err error,
+) {
+	apexName, primaryName, canaryName := canary.GetServiceNames()
+	ts, err := sr.smiClient.SplitV1alpha2().TrafficSplits(canary.Namespace).Get(context.TODO(), apexName, metav1.GetOptions{})
+	if err != nil {
+		err = fmt.Errorf("TrafficSplit %s.%s get query error %v", apexName, canary.Namespace, err)
+		return
+	}
+
+	for _, r := range ts.Spec.Backends {
+		if r.Service == primaryName {
+			primaryWeight = r.Weight
+		}
+		if r.Service == canaryName {
+			canaryWeight = r.Weight
+		}
+	}
+
+	if primaryWeight == 0 && canaryWeight == 0 {
+		err = fmt.Errorf("TrafficSplit %s.%s does not contain routes for %s and %s",
+			apexName, canary.Namespace, primaryName, canaryName)
+	}
+
+	mirrored = false
+
+	return
+}
+
+// SetRoutes updates the destinations weight for primary and canary
+func (sr *Smiv1alpha2Router) SetRoutes(
+	canary *flaggerv1.Canary,
+	primaryWeight int,
+	canaryWeight int,
+	_ bool,
+) error {
+	apexName, primaryName, canaryName := canary.GetServiceNames()
+	ts, err := sr.smiClient.SplitV1alpha2().TrafficSplits(canary.Namespace).Get(context.TODO(), apexName, metav1.GetOptions{})
+	if err != nil {
+		return fmt.Errorf("TrafficSplit %s.%s get query error %v", apexName, canary.Namespace, err)
+	}
+
+	backends := []smiv1alpha2.TrafficSplitBackend{
+		{
+			Service: canaryName,
+			Weight:  canaryWeight,
+		},
+		{
+			Service: primaryName,
+			Weight:  primaryWeight,
+		},
+	}
+
+	tsClone := ts.DeepCopy()
+	tsClone.Spec.Backends = backends
+
+	_, err = sr.smiClient.SplitV1alpha2().TrafficSplits(canary.Namespace).Update(context.TODO(), tsClone, metav1.UpdateOptions{})
+	if err != nil {
+		return fmt.Errorf("TrafficSplit %s.%s update error %v", apexName, canary.Namespace, err)
+	}
+
+	return nil
+}
+
+func (sr *Smiv1alpha2Router) makeAnnotations(gateways []string) map[string]string {
+	res := make(map[string]string)
+	if sr.targetMesh == "istio" && len(gateways) > 0 {
+		g, _ := json.Marshal(gateways)
+		res["VirtualService.v1alpha3.networking.istio.io/spec.gateways"] = string(g)
+	}
+	return res
+}
+
+func (sr *Smiv1alpha2Router) Finalize(_ *flaggerv1.Canary) error {
+	return nil
+}


Test patch:
diff --git a/pkg/router/smi_v1alpha2_test.go b/pkg/router/smi_v1alpha2_test.go
new file mode 100644
index 00000000..de82f562
--- /dev/null
+++ b/pkg/router/smi_v1alpha2_test.go
@@ -0,0 +1,138 @@
+/*
+Copyright 2020 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package router
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	smiv1 "github.com/fluxcd/flagger/pkg/apis/smi/v1alpha2"
+)
+
+func TestSmiv1alpha2Router_Sync(t *testing.T) {
+	canary := newTestSMICanary()
+	mocks := newFixture(canary)
+	router := &Smiv1alpha2Router{
+		logger:        mocks.logger,
+		flaggerClient: mocks.flaggerClient,
+		smiClient:     mocks.meshClient,
+		kubeClient:    mocks.kubeClient,
+	}
+
+	err := router.Reconcile(canary)
+	require.NoError(t, err)
+
+	// test insert
+	ts, err := router.smiClient.SplitV1alpha2().TrafficSplits("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
+	require.NoError(t, err)
+	dests := ts.Spec.Backends
+	assert.Len(t, dests, 2)
+
+	apexName, primaryName, canaryName := canary.GetServiceNames()
+	assert.Equal(t, ts.Spec.Service, apexName)
+
+	var pRoute smiv1.TrafficSplitBackend
+	var cRoute smiv1.TrafficSplitBackend
+	for _, dest := range ts.Spec.Backends {
+		if dest.Service == primaryName {
+			pRoute = dest
+		}
+		if dest.Service == canaryName {
+			cRoute = dest
+		}
+	}
+
+	assert.Equal(t, 100, pRoute.Weight)
+	assert.Equal(t, 0, cRoute.Weight)
+
+	// test update
+	host := "test"
+	canary.Spec.Service.Name = host
+
+	err = router.Reconcile(canary)
+	require.NoError(t, err)
+
+	ts, err = router.smiClient.SplitV1alpha2().TrafficSplits("default").Get(context.TODO(), "test", metav1.GetOptions{})
+	require.NoError(t, err)
+	assert.Equal(t, host, ts.Spec.Service)
+}
+
+func TestSmiv1alpha2Router_SetRoutes(t *testing.T) {
+	canary := newTestSMICanary()
+	mocks := newFixture(canary)
+	router := &Smiv1alpha2Router{
+		logger:        mocks.logger,
+		flaggerClient: mocks.flaggerClient,
+		smiClient:     mocks.meshClient,
+		kubeClient:    mocks.kubeClient,
+	}
+
+	err := router.Reconcile(mocks.canary)
+	require.NoError(t, err)
+
+	p, c, m, err := router.GetRoutes(mocks.canary)
+	require.NoError(t, err)
+
+	p = 50
+	c = 50
+	m = false
+
+	err = router.SetRoutes(mocks.canary, p, c, m)
+	require.NoError(t, err)
+
+	ts, err := router.smiClient.SplitV1alpha2().TrafficSplits("default").Get(context.TODO(), "podinfo", metav1.GetOptions{})
+	require.NoError(t, err)
+
+	var pRoute smiv1.TrafficSplitBackend
+	var cRoute smiv1.TrafficSplitBackend
+	_, primaryName, canaryName := canary.GetServiceNames()
+
+	for _, dest := range ts.Spec.Backends {
+		if dest.Service == primaryName {
+			pRoute = dest
+		}
+		if dest.Service == canaryName {
+			cRoute = dest
+		}
+	}
+
+	assert.Equal(t, p, pRoute.Weight)
+	assert.Equal(t, c, cRoute.Weight)
+}
+
+func TestSmiv1alpha2Router_GetRoutes(t *testing.T) {
+	mocks := newFixture(nil)
+	router := &Smiv1alpha2Router{
+		logger:        mocks.logger,
+		flaggerClient: mocks.flaggerClient,
+		smiClient:     mocks.meshClient,
+		kubeClient:    mocks.kubeClient,
+	}
+
+	err := router.Reconcile(mocks.canary)
+	require.NoError(t, err)
+
+	p, c, m, err := router.GetRoutes(mocks.canary)
+	require.NoError(t, err)
+	assert.Equal(t, 100, p)
+	assert.Equal(t, 0, c)
+	assert.False(t, m)
+}