mirror of
https://github.com/fluxcd/flagger.git
synced 2026-04-15 06:57:34 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
@@ -9,4 +9,5 @@ const (
|
||||
GlooProvider string = "gloo"
|
||||
NGINXProvider string = "nginx"
|
||||
KubernetesProvider string = "kubernetes"
|
||||
SkipperProvider string = "skipper"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -29,6 +30,8 @@ type ConfigRefType string
|
||||
const (
|
||||
ConfigRefMap ConfigRefType = "configmap"
|
||||
ConfigRefSecret ConfigRefType = "secret"
|
||||
|
||||
configTrackingDisabledAnnotationKey = "flagger.app/config-tracking"
|
||||
)
|
||||
|
||||
// ConfigRef holds the reference to a tracked Kubernetes ConfigMap or Secret
|
||||
@@ -50,6 +53,10 @@ func checksum(data interface{}) string {
|
||||
return fmt.Sprintf("%x", hashBytes[:8])
|
||||
}
|
||||
|
||||
func configIsDisabled(annotations map[string]string) bool {
|
||||
return strings.HasPrefix(annotations[configTrackingDisabledAnnotationKey], "disable")
|
||||
}
|
||||
|
||||
// getRefFromConfigMap transforms a Kubernetes ConfigMap into a ConfigRef
|
||||
// and computes the checksum of the ConfigMap data
|
||||
func (ct *ConfigTracker) getRefFromConfigMap(name string, namespace string) (*ConfigRef, error) {
|
||||
@@ -58,6 +65,10 @@ func (ct *ConfigTracker) getRefFromConfigMap(name string, namespace string) (*Co
|
||||
return nil, fmt.Errorf("configmap %s.%s get query error: %w", name, namespace, err)
|
||||
}
|
||||
|
||||
if configIsDisabled(config.GetAnnotations()) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &ConfigRef{
|
||||
Name: config.Name,
|
||||
Type: ConfigRefMap,
|
||||
@@ -82,6 +93,10 @@ func (ct *ConfigTracker) getRefFromSecret(name string, namespace string) (*Confi
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if configIsDisabled(secret.GetAnnotations()) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &ConfigRef{
|
||||
Name: secret.Name,
|
||||
Type: ConfigRefSecret,
|
||||
@@ -92,23 +107,22 @@ func (ct *ConfigTracker) getRefFromSecret(name string, namespace string) (*Confi
|
||||
// GetTargetConfigs scans the target deployment for Kubernetes ConfigMaps and Secretes
|
||||
// and returns a list of config references
|
||||
func (ct *ConfigTracker) GetTargetConfigs(cd *flaggerv1.Canary) (map[string]ConfigRef, error) {
|
||||
res := make(map[string]ConfigRef)
|
||||
targetName := cd.Spec.TargetRef.Name
|
||||
|
||||
var vs []corev1.Volume
|
||||
var cs []corev1.Container
|
||||
|
||||
switch cd.Spec.TargetRef.Kind {
|
||||
case "Deployment":
|
||||
targetDep, err := ct.KubeClient.AppsV1().Deployments(cd.Namespace).Get(context.TODO(), targetName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("deployment %s.%s get query error: %w", targetName, cd.Namespace, err)
|
||||
return nil, fmt.Errorf("deployment %s.%s get query error: %w", targetName, cd.Namespace, err)
|
||||
}
|
||||
vs = targetDep.Spec.Template.Spec.Volumes
|
||||
cs = targetDep.Spec.Template.Spec.Containers
|
||||
case "DaemonSet":
|
||||
targetDae, err := ct.KubeClient.AppsV1().DaemonSets(cd.Namespace).Get(context.TODO(), targetName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("daemonset %s.%s get query error: %w", targetName, cd.Namespace, err)
|
||||
return nil, fmt.Errorf("daemonset %s.%s get query error: %w", targetName, cd.Namespace, err)
|
||||
}
|
||||
vs = targetDae.Spec.Template.Spec.Volumes
|
||||
cs = targetDae.Spec.Template.Spec.Containers
|
||||
@@ -116,48 +130,31 @@ func (ct *ConfigTracker) GetTargetConfigs(cd *flaggerv1.Canary) (map[string]Conf
|
||||
return nil, fmt.Errorf("TargetRef.Kind invalid: %s", cd.Spec.TargetRef.Kind)
|
||||
}
|
||||
|
||||
type void struct{}
|
||||
var member void
|
||||
secretNames := map[string]void{}
|
||||
configMapNames := map[string]void{}
|
||||
|
||||
// scan volumes
|
||||
for _, volume := range vs {
|
||||
if cmv := volume.ConfigMap; cmv != nil {
|
||||
config, err := ct.getRefFromConfigMap(cmv.Name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromConfigMap failed: %v", err)
|
||||
continue
|
||||
}
|
||||
res[config.GetName()] = *config
|
||||
name := cmv.Name
|
||||
configMapNames[name] = member
|
||||
}
|
||||
|
||||
if sv := volume.Secret; sv != nil {
|
||||
secret, err := ct.getRefFromSecret(sv.SecretName, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromSecret failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if secret != nil {
|
||||
res[secret.GetName()] = *secret
|
||||
}
|
||||
name := sv.SecretName
|
||||
secretNames[name] = member
|
||||
}
|
||||
|
||||
if projected := volume.Projected; projected != nil {
|
||||
for _, source := range projected.Sources {
|
||||
if cmv := source.ConfigMap; cmv != nil {
|
||||
config, err := ct.getRefFromConfigMap(cmv.Name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromConfigMap failed: %v", err)
|
||||
continue
|
||||
}
|
||||
res[config.GetName()] = *config
|
||||
name := cmv.Name
|
||||
configMapNames[name] = member
|
||||
}
|
||||
|
||||
if sv := source.Secret; sv != nil {
|
||||
secret, err := ct.getRefFromSecret(sv.Name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromSecret failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if secret != nil {
|
||||
res[secret.GetName()] = *secret
|
||||
}
|
||||
name := sv.Name
|
||||
secretNames[name] = member
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,22 +167,10 @@ func (ct *ConfigTracker) GetTargetConfigs(cd *flaggerv1.Canary) (map[string]Conf
|
||||
switch {
|
||||
case env.ValueFrom.ConfigMapKeyRef != nil:
|
||||
name := env.ValueFrom.ConfigMapKeyRef.LocalObjectReference.Name
|
||||
config, err := ct.getRefFromConfigMap(name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromConfigMap failed: %v", err)
|
||||
continue
|
||||
}
|
||||
res[config.GetName()] = *config
|
||||
configMapNames[name] = member
|
||||
case env.ValueFrom.SecretKeyRef != nil:
|
||||
name := env.ValueFrom.SecretKeyRef.LocalObjectReference.Name
|
||||
secret, err := ct.getRefFromSecret(name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromSecret failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if secret != nil {
|
||||
res[secret.GetName()] = *secret
|
||||
}
|
||||
secretNames[name] = member
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,26 +179,37 @@ func (ct *ConfigTracker) GetTargetConfigs(cd *flaggerv1.Canary) (map[string]Conf
|
||||
switch {
|
||||
case envFrom.ConfigMapRef != nil:
|
||||
name := envFrom.ConfigMapRef.LocalObjectReference.Name
|
||||
config, err := ct.getRefFromConfigMap(name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromConfigMap failed %v", err)
|
||||
continue
|
||||
}
|
||||
res[config.GetName()] = *config
|
||||
configMapNames[name] = member
|
||||
case envFrom.SecretRef != nil:
|
||||
name := envFrom.SecretRef.LocalObjectReference.Name
|
||||
secret, err := ct.getRefFromSecret(name, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromSecret failed %v", err)
|
||||
continue
|
||||
}
|
||||
if secret != nil {
|
||||
res[secret.GetName()] = *secret
|
||||
}
|
||||
secretNames[name] = member
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res := make(map[string]ConfigRef)
|
||||
|
||||
for configMapName := range configMapNames {
|
||||
config, err := ct.getRefFromConfigMap(configMapName, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromConfigMap failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if config != nil {
|
||||
res[config.GetName()] = *config
|
||||
}
|
||||
}
|
||||
for secretName := range secretNames {
|
||||
secret, err := ct.getRefFromSecret(secretName, cd.Namespace)
|
||||
if err != nil {
|
||||
ct.Logger.Errorf("getRefFromSecret failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if secret != nil {
|
||||
res[secret.GetName()] = *secret
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,19 @@ import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestConfigIsDisabled(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
annotations map[string]string
|
||||
exp bool
|
||||
}{
|
||||
{annotations: map[string]string{configTrackingDisabledAnnotationKey: "disable"}, exp: true},
|
||||
{annotations: map[string]string{"app": "disable"}, exp: false},
|
||||
{annotations: map[string]string{}, exp: false},
|
||||
} {
|
||||
assert.Equal(t, configIsDisabled(c.annotations), c.exp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigTracker_ConfigMaps(t *testing.T) {
|
||||
t.Run("deployment", func(t *testing.T) {
|
||||
mocks := newDeploymentFixture()
|
||||
@@ -46,6 +59,33 @@ func TestConfigTracker_ConfigMaps(t *testing.T) {
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, configMapProjected.Data["color"], configPrimaryProjected.Data["color"])
|
||||
}
|
||||
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-enabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-enabled-primary", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-disabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-disabled-primary", metav1.GetOptions{})
|
||||
assert.Error(t, err)
|
||||
|
||||
var trackedVolPresent, originalVolPresent bool
|
||||
for _, vol := range depPrimary.Spec.Template.Spec.Volumes {
|
||||
if vol.ConfigMap != nil {
|
||||
switch vol.ConfigMap.Name {
|
||||
case "podinfo-config-tracker-enabled":
|
||||
assert.Fail(t, "primary Deployment does not contain a volume for config-tracked configmap %q", vol.ConfigMap.Name)
|
||||
case "podinfo-config-tracker-enabled-primary":
|
||||
trackedVolPresent = true
|
||||
case "podinfo-config-tracker-disabled":
|
||||
originalVolPresent = true
|
||||
case "podinfo-config-tracker-disabled-primary":
|
||||
assert.Fail(t, "primary Deployment incorrectly contains a volume for a copy of an untracked configmap %q", vol.ConfigMap.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, trackedVolPresent, "Volume for primary copy of config-tracked configmap should be present")
|
||||
assert.True(t, originalVolPresent, "Volume for original configmap with disabled tracking should be present")
|
||||
})
|
||||
|
||||
t.Run("daemonset", func(t *testing.T) {
|
||||
@@ -56,10 +96,10 @@ func TestConfigTracker_ConfigMaps(t *testing.T) {
|
||||
err := mocks.controller.Initialize(mocks.canary)
|
||||
require.NoError(t, err)
|
||||
|
||||
depPrimary, err := mocks.kubeClient.AppsV1().DaemonSets("default").Get(context.TODO(), "podinfo-primary", metav1.GetOptions{})
|
||||
daePrimary, err := mocks.kubeClient.AppsV1().DaemonSets("default").Get(context.TODO(), "podinfo-primary", metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
configPrimaryVolName := depPrimary.Spec.Template.Spec.Volumes[0].VolumeSource.ConfigMap.LocalObjectReference.Name
|
||||
configPrimaryVolName := daePrimary.Spec.Template.Spec.Volumes[0].VolumeSource.ConfigMap.LocalObjectReference.Name
|
||||
assert.Equal(t, "podinfo-config-vol-primary", configPrimaryVolName)
|
||||
|
||||
configPrimary, err := mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-env-primary", metav1.GetOptions{})
|
||||
@@ -77,13 +117,40 @@ func TestConfigTracker_ConfigMaps(t *testing.T) {
|
||||
assert.Equal(t, configMap.Data["color"], configPrimaryVol.Data["color"])
|
||||
}
|
||||
|
||||
configProjectedName := depPrimary.Spec.Template.Spec.Volumes[2].VolumeSource.Projected.Sources[0].ConfigMap.Name
|
||||
configProjectedName := daePrimary.Spec.Template.Spec.Volumes[2].VolumeSource.Projected.Sources[0].ConfigMap.Name
|
||||
assert.Equal(t, "podinfo-config-projected-primary", configProjectedName)
|
||||
|
||||
configPrimaryProjected, err := mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-vol-primary", metav1.GetOptions{})
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, configMapProjected.Data["color"], configPrimaryProjected.Data["color"])
|
||||
}
|
||||
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-enabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-enabled-primary", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-disabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().ConfigMaps("default").Get(context.TODO(), "podinfo-config-tracker-disabled-primary", metav1.GetOptions{})
|
||||
assert.Error(t, err)
|
||||
|
||||
var trackedVolPresent, originalVolPresent bool
|
||||
for _, vol := range daePrimary.Spec.Template.Spec.Volumes {
|
||||
if vol.ConfigMap != nil {
|
||||
switch vol.ConfigMap.Name {
|
||||
case "podinfo-config-tracker-enabled":
|
||||
assert.Fail(t, "primary Deployment does not contain a volume for config-tracked configmap %q", vol.ConfigMap.Name)
|
||||
case "podinfo-config-tracker-enabled-primary":
|
||||
trackedVolPresent = true
|
||||
case "podinfo-config-tracker-disabled":
|
||||
originalVolPresent = true
|
||||
case "podinfo-config-tracker-disabled-primary":
|
||||
assert.Fail(t, "primary Deployment incorrectly contains a volume for a copy of an untracked configmap %q", vol.ConfigMap.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, trackedVolPresent, "Volume for primary copy of config-tracked configmap should be present")
|
||||
assert.True(t, originalVolPresent, "Volume for original configmap with disabled tracking should be present")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +190,33 @@ func TestConfigTracker_Secrets(t *testing.T) {
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, string(secretProjected.Data["apiKey"]), string(secretPrimaryProjected.Data["apiKey"]))
|
||||
}
|
||||
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-enabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-enabled-primary", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-disabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-disabled-primary", metav1.GetOptions{})
|
||||
assert.Error(t, err)
|
||||
|
||||
var trackedVolPresent, originalVolPresent bool
|
||||
for _, vol := range depPrimary.Spec.Template.Spec.Volumes {
|
||||
if vol.Secret != nil {
|
||||
switch vol.Secret.SecretName {
|
||||
case "podinfo-secret-tracker-enabled":
|
||||
assert.Fail(t, "primary Deployment does not contain a volume for config-tracked secret %q", vol.Secret.SecretName)
|
||||
case "podinfo-secret-tracker-enabled-primary":
|
||||
trackedVolPresent = true
|
||||
case "podinfo-secret-tracker-disabled":
|
||||
originalVolPresent = true
|
||||
case "podinfo-secret-tracker-disabled-primary":
|
||||
assert.Fail(t, "primary Deployment incorrectly contains a volume for a copy of an untracked secret %q", vol.Secret.SecretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, trackedVolPresent, "Volume for primary copy of config-tracked secret should be present")
|
||||
assert.True(t, originalVolPresent, "Volume for original secret with disabled tracking should be present")
|
||||
})
|
||||
|
||||
t.Run("daemonset", func(t *testing.T) {
|
||||
@@ -160,5 +254,32 @@ func TestConfigTracker_Secrets(t *testing.T) {
|
||||
if assert.NoError(t, err) {
|
||||
assert.Equal(t, string(secretProjected.Data["apiKey"]), string(secretPrimaryProjected.Data["apiKey"]))
|
||||
}
|
||||
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-enabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-enabled-primary", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-disabled", metav1.GetOptions{})
|
||||
assert.NoError(t, err)
|
||||
_, err = mocks.kubeClient.CoreV1().Secrets("default").Get(context.TODO(), "podinfo-secret-tracker-disabled-primary", metav1.GetOptions{})
|
||||
assert.Error(t, err)
|
||||
|
||||
var trackedVolPresent, originalVolPresent bool
|
||||
for _, vol := range daePrimary.Spec.Template.Spec.Volumes {
|
||||
if vol.Secret != nil {
|
||||
switch vol.Secret.SecretName {
|
||||
case "podinfo-secret-tracker-enabled":
|
||||
assert.Fail(t, "primary Deployment does not contain a volume for config-tracked secret %q", vol.Secret.SecretName)
|
||||
case "podinfo-secret-tracker-enabled-primary":
|
||||
trackedVolPresent = true
|
||||
case "podinfo-secret-tracker-disabled":
|
||||
originalVolPresent = true
|
||||
case "podinfo-secret-tracker-disabled-primary":
|
||||
assert.Fail(t, "primary Deployment incorrectly contains a volume for a copy of an untracked secret %q", vol.Secret.SecretName)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, trackedVolPresent, "Volume for primary copy of config-tracked secret should be present")
|
||||
assert.True(t, originalVolPresent, "Volume for original secret with disabled tracking should be present")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,10 +35,14 @@ func newDaemonSetFixture() daemonSetControllerFixture {
|
||||
newDaemonSetControllerTestConfigMapEnv(),
|
||||
newDaemonSetControllerTestConfigMapVol(),
|
||||
newDaemonSetControllerTestConfigProjected(),
|
||||
newDaemonSetControllerTestConfigMapTrackerEnabled(),
|
||||
newDaemonSetControllerTestConfigMapTrackerDisabled(),
|
||||
newDaemonSetControllerTestSecret(),
|
||||
newDaemonSetControllerTestSecretEnv(),
|
||||
newDaemonSetControllerTestSecretVol(),
|
||||
newDaemonSetControllerTestSecretProjected(),
|
||||
newDaemonSetControllerTestSecretTrackerEnabled(),
|
||||
newDaemonSetControllerTestSecretTrackerDisabled(),
|
||||
)
|
||||
|
||||
logger, _ := logger.NewLogger("debug")
|
||||
@@ -130,6 +134,42 @@ func newDaemonSetControllerTestConfigMapVol() *corev1.ConfigMap {
|
||||
}
|
||||
}
|
||||
|
||||
func newDaemonSetControllerTestConfigMapTrackerEnabled() *corev1.ConfigMap {
|
||||
return &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-config-tracker-enabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": ":)",
|
||||
"flagger.app/config-tracking": "enabled",
|
||||
"unrelated-annotation-2": "<3",
|
||||
},
|
||||
},
|
||||
Data: map[string]string{
|
||||
"color": "red",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDaemonSetControllerTestConfigMapTrackerDisabled() *corev1.ConfigMap {
|
||||
return &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-config-tracker-disabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": "c:",
|
||||
"flagger.app/config-tracking": "disabled",
|
||||
"unrelated-annotation-2": "^-^",
|
||||
},
|
||||
},
|
||||
Data: map[string]string{
|
||||
"color": "red",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDaemonSetControllerTestSecret() *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
@@ -186,6 +226,44 @@ func newDaemonSetControllerTestSecretVol() *corev1.Secret {
|
||||
}
|
||||
}
|
||||
|
||||
func newDaemonSetControllerTestSecretTrackerEnabled() *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-secret-tracker-enabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": ":)",
|
||||
"flagger.app/config-tracking": "enabled",
|
||||
"unrelated-annotation-2": "<3",
|
||||
},
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{
|
||||
"apiKey": []byte("test"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDaemonSetControllerTestSecretTrackerDisabled() *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-secret-tracker-disabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": "c:",
|
||||
"flagger.app/config-tracking": "disabled",
|
||||
"unrelated-annotation-2": "^-^",
|
||||
},
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{
|
||||
"apiKey": []byte("test"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDaemonSetControllerTestCanary() *flaggerv1.Canary {
|
||||
cd := &flaggerv1.Canary{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: flaggerv1.SchemeGroupVersion.String()},
|
||||
@@ -297,6 +375,26 @@ func newDaemonSetControllerTestPodInfo() *appsv1.DaemonSet {
|
||||
MountPath: "/etc/podinfo/secret",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -354,6 +452,42 @@ func newDaemonSetControllerTestPodInfo() *appsv1.DaemonSet {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -441,6 +575,26 @@ func newDaemonSetControllerTestPodInfoV2() *appsv1.DaemonSet {
|
||||
MountPath: "/etc/podinfo/secret",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -498,6 +652,42 @@ func newDaemonSetControllerTestPodInfoV2() *appsv1.DaemonSet {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -64,10 +64,14 @@ func newDeploymentFixture() deploymentControllerFixture {
|
||||
newDeploymentControllerTestConfigMapEnv(),
|
||||
newDeploymentControllerTestConfigMapVol(),
|
||||
newDeploymentControllerTestConfigProjected(),
|
||||
newDeploymentControllerTestConfigMapTrackerEnabled(),
|
||||
newDeploymentControllerTestConfigMapTrackerDisabled(),
|
||||
newDeploymentControllerTestSecret(),
|
||||
newDeploymentControllerTestSecretEnv(),
|
||||
newDeploymentControllerTestSecretVol(),
|
||||
newDeploymentControllerTestSecretProjected(),
|
||||
newDeploymentControllerTestSecretTrackerEnabled(),
|
||||
newDeploymentControllerTestSecretTrackerDisabled(),
|
||||
)
|
||||
|
||||
logger, _ := logger.NewLogger("debug")
|
||||
@@ -146,6 +150,42 @@ func newDeploymentControllerTestConfigMapEnv() *corev1.ConfigMap {
|
||||
}
|
||||
}
|
||||
|
||||
func newDeploymentControllerTestConfigMapTrackerEnabled() *corev1.ConfigMap {
|
||||
return &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-config-tracker-enabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": ":)",
|
||||
"flagger.app/config-tracking": "enabled",
|
||||
"unrelated-annotation-2": "<3",
|
||||
},
|
||||
},
|
||||
Data: map[string]string{
|
||||
"color": "red",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDeploymentControllerTestConfigMapTrackerDisabled() *corev1.ConfigMap {
|
||||
return &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-config-tracker-disabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": "c:",
|
||||
"flagger.app/config-tracking": "disabled",
|
||||
"unrelated-annotation-2": "^-^",
|
||||
},
|
||||
},
|
||||
Data: map[string]string{
|
||||
"color": "red",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDeploymentControllerTestConfigMapVol() *corev1.ConfigMap {
|
||||
return &corev1.ConfigMap{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
@@ -215,6 +255,44 @@ func newDeploymentControllerTestSecretVol() *corev1.Secret {
|
||||
}
|
||||
}
|
||||
|
||||
func newDeploymentControllerTestSecretTrackerEnabled() *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-secret-tracker-enabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": ":)",
|
||||
"flagger.app/config-tracking": "enabled",
|
||||
"unrelated-annotation-2": "<3",
|
||||
},
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{
|
||||
"apiKey": []byte("test"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDeploymentControllerTestSecretTrackerDisabled() *corev1.Secret {
|
||||
return &corev1.Secret{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: corev1.SchemeGroupVersion.String()},
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "default",
|
||||
Name: "podinfo-secret-tracker-disabled",
|
||||
Annotations: map[string]string{
|
||||
"unrelated-annotation-1": "c:",
|
||||
"flagger.app/config-tracking": "disabled",
|
||||
"unrelated-annotation-2": "^-^",
|
||||
},
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: map[string][]byte{
|
||||
"apiKey": []byte("test"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newDeploymentControllerTestCanary() *flaggerv1.Canary {
|
||||
cd := &flaggerv1.Canary{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: flaggerv1.SchemeGroupVersion.String()},
|
||||
@@ -337,6 +415,26 @@ func newDeploymentControllerTest() *appsv1.Deployment {
|
||||
MountPath: "/etc/podinfo/secret",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -394,6 +492,42 @@ func newDeploymentControllerTest() *appsv1.Deployment {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -482,6 +616,26 @@ func newDeploymentControllerTestV2() *appsv1.Deployment {
|
||||
MountPath: "/etc/podinfo/secret",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/config-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-enabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
MountPath: "/etc/podinfo/secret-tracker-disabled",
|
||||
ReadOnly: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -539,6 +693,42 @@ func newDeploymentControllerTestV2() *appsv1.Deployment {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "config-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
ConfigMap: &corev1.ConfigMapVolumeSource{
|
||||
LocalObjectReference: corev1.LocalObjectReference{
|
||||
Name: "podinfo-config-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-enabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "secret-tracker-disabled",
|
||||
VolumeSource: corev1.VolumeSource{
|
||||
Secret: &corev1.SecretVolumeSource{
|
||||
SecretName: "podinfo-secret-tracker-disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -56,6 +56,10 @@ func (factory Factory) Observer(provider string) Interface {
|
||||
return &HttpObserver{
|
||||
client: factory.Client,
|
||||
}
|
||||
case provider == flaggerv1.SkipperProvider:
|
||||
return &SkipperObserver{
|
||||
client: factory.Client,
|
||||
}
|
||||
default:
|
||||
return &IstioObserver{
|
||||
client: factory.Client,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package observers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
flaggerv1 "github.com/weaveworks/flagger/pkg/apis/flagger/v1beta1"
|
||||
"github.com/weaveworks/flagger/pkg/logger"
|
||||
|
||||
"github.com/weaveworks/flagger/pkg/metrics/providers"
|
||||
)
|
||||
|
||||
const routePattern = `{{- $route := printf "kube(ew)?_%s__%s_canary__.*__%s_canary(_[0-9]+)?" namespace ingress service }}`
|
||||
|
||||
var skipperQueries = map[string]string{
|
||||
"request-success-rate": routePattern + `
|
||||
sum(rate(skipper_response_duration_seconds_bucket{route=~"{{ $route }}",code!~"5..",le="+Inf"}[{{ interval }}])) /
|
||||
sum(rate(skipper_response_duration_seconds_bucket{route=~"{{ $route }}",le="+Inf"}[{{ interval }}])) * 100`,
|
||||
"request-duration": routePattern + `
|
||||
sum(rate(skipper_serve_route_duration_seconds_sum{route=~"{{ $route }}"}[{{ interval }}])) /
|
||||
sum(rate(skipper_serve_route_duration_seconds_count{route=~"{{ $route }}"}[{{ interval }}])) * 1000`,
|
||||
}
|
||||
|
||||
// SkipperObserver Implementation for Skipper (https://github.com/zalando/skipper)
|
||||
type SkipperObserver struct {
|
||||
client providers.Interface
|
||||
}
|
||||
|
||||
// GetRequestSuccessRate return value for Skipper Request Success Rate
|
||||
func (ob *SkipperObserver) GetRequestSuccessRate(model flaggerv1.MetricTemplateModel) (float64, error) {
|
||||
|
||||
model = encodeModelForSkipper(model)
|
||||
|
||||
query, err := RenderQuery(skipperQueries["request-success-rate"], model)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rendering query failed: %w", err)
|
||||
}
|
||||
logger, _ := logger.NewLoggerWithEncoding("debug", "json")
|
||||
logger.Debugf("GetRequestSuccessRate: %s", query)
|
||||
|
||||
value, err := ob.client.RunQuery(query)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("running query failed: %w", err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// GetRequestDuration return value for Skipper Request Duration
|
||||
func (ob *SkipperObserver) GetRequestDuration(model flaggerv1.MetricTemplateModel) (time.Duration, error) {
|
||||
|
||||
model = encodeModelForSkipper(model)
|
||||
|
||||
query, err := RenderQuery(skipperQueries["request-duration"], model)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("rendering query failed: %w", err)
|
||||
}
|
||||
logger, _ := logger.NewLoggerWithEncoding("debug", "json")
|
||||
logger.Debugf("GetRequestDuration: %s", query)
|
||||
|
||||
value, err := ob.client.RunQuery(query)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("running query failed: %w", err)
|
||||
}
|
||||
|
||||
ms := time.Duration(int64(value)) * time.Millisecond
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// encodeModelForSkipper replaces non word character in model with underscore to match route names
|
||||
// https://github.com/zalando/skipper/blob/dd70bd65e7f99cfb5dd6b6f71885d9fe3b2707f6/dataclients/kubernetes/ingress.go#L101
|
||||
func encodeModelForSkipper(model flaggerv1.MetricTemplateModel) flaggerv1.MetricTemplateModel {
|
||||
nonWord := regexp.MustCompile(`\W`)
|
||||
model.Ingress = nonWord.ReplaceAllString(model.Ingress, "_")
|
||||
model.Name = nonWord.ReplaceAllString(model.Name, "_")
|
||||
model.Namespace = nonWord.ReplaceAllString(model.Namespace, "_")
|
||||
model.Service = nonWord.ReplaceAllString(model.Service, "_")
|
||||
model.Target = nonWord.ReplaceAllString(model.Target, "_")
|
||||
|
||||
return model
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package observers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
flaggerv1 "github.com/weaveworks/flagger/pkg/apis/flagger/v1beta1"
|
||||
"github.com/weaveworks/flagger/pkg/metrics/providers"
|
||||
)
|
||||
|
||||
func TestSkipperObserver_GetRequestSuccessRate(t *testing.T) {
|
||||
t.Run("ok", func(t *testing.T) {
|
||||
expected := ` sum(rate(skipper_response_duration_seconds_bucket{route=~"kube(ew)?_skipper__skipper_ingress_canary__.*__backend_canary(_[0-9]+)?",code!~"5..",le="+Inf"}[1m])) / sum(rate(skipper_response_duration_seconds_bucket{route=~"kube(ew)?_skipper__skipper_ingress_canary__.*__backend_canary(_[0-9]+)?",le="+Inf"}[1m])) * 100`
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
promql := r.URL.Query()["query"][0]
|
||||
assert.Equal(t, expected, promql)
|
||||
|
||||
json := `{"status":"success","data":{"resultType":"vector","result":[{"metric":{},"value":[1,"100"]}]}}`
|
||||
w.Write([]byte(json))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := providers.NewPrometheusProvider(flaggerv1.MetricTemplateProvider{
|
||||
Type: "prometheus",
|
||||
Address: ts.URL,
|
||||
SecretRef: nil,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
observer := &SkipperObserver{client: client}
|
||||
val, err := observer.GetRequestSuccessRate(flaggerv1.MetricTemplateModel{
|
||||
Namespace: "skipper",
|
||||
Interval: "1m",
|
||||
Service: "backend",
|
||||
Ingress: "skipper-ingress",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(100), val)
|
||||
})
|
||||
|
||||
t.Run("no values", func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json := `{"status":"success","data":{"resultType":"vector","result":[]}}`
|
||||
w.Write([]byte(json))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := providers.NewPrometheusProvider(flaggerv1.MetricTemplateProvider{
|
||||
Type: "prometheus",
|
||||
Address: ts.URL,
|
||||
SecretRef: nil,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
observer := &SkipperObserver{client: client}
|
||||
_, err = observer.GetRequestSuccessRate(flaggerv1.MetricTemplateModel{})
|
||||
require.True(t, errors.Is(err, providers.ErrNoValuesFound))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkipperObserver_GetRequestDuration(t *testing.T) {
|
||||
expected := ` sum(rate(skipper_serve_route_duration_seconds_sum{route=~"kube(ew)?_skipper__skipper_ingress_canary__.*__backend_canary(_[0-9]+)?"}[1m])) / sum(rate(skipper_serve_route_duration_seconds_count{route=~"kube(ew)?_skipper__skipper_ingress_canary__.*__backend_canary(_[0-9]+)?"}[1m])) * 1000`
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
promql := r.URL.Query()["query"][0]
|
||||
assert.Equal(t, expected, promql)
|
||||
|
||||
json := `{"status":"success","data":{"resultType":"vector","result":[{"metric":{},"value":[1,"100"]}]}}`
|
||||
w.Write([]byte(json))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
client, err := providers.NewPrometheusProvider(flaggerv1.MetricTemplateProvider{
|
||||
Type: "prometheus",
|
||||
Address: ts.URL,
|
||||
SecretRef: nil,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
observer := &SkipperObserver{client: client}
|
||||
val, err := observer.GetRequestDuration(flaggerv1.MetricTemplateModel{
|
||||
Namespace: "skipper",
|
||||
Interval: "1m",
|
||||
Service: "backend",
|
||||
Ingress: "skipper-ingress",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 100*time.Millisecond, val)
|
||||
}
|
||||
@@ -122,6 +122,11 @@ func (factory *Factory) MeshRouter(provider string, labelSelector string) Interf
|
||||
kubeClient: factory.kubeClient,
|
||||
annotationsPrefix: factory.ingressAnnotationsPrefix,
|
||||
}
|
||||
case provider == flaggerv1.SkipperProvider:
|
||||
return &SkipperRouter{
|
||||
logger: factory.logger,
|
||||
kubeClient: factory.kubeClient,
|
||||
}
|
||||
case provider == flaggerv1.KubernetesProvider:
|
||||
return &NopRouter{}
|
||||
default:
|
||||
|
||||
@@ -125,9 +125,6 @@ func (c *KubernetesDefaultRouter) reconcileService(canary *flaggerv1.Canary, nam
|
||||
metadata.Annotations = make(map[string]string)
|
||||
}
|
||||
|
||||
c.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
|
||||
Debugw(fmt.Sprintf("Creating Service %s", name), "metadata", metadata, "service_configuration", canary.Spec.Service)
|
||||
|
||||
// create service if it doesn't exists
|
||||
svc, err := c.kubeClient.CoreV1().Services(canary.Namespace).Get(context.TODO(), name, metav1.GetOptions{})
|
||||
if errors.IsNotFound(err) {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"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/weaveworks/flagger/pkg/apis/flagger/v1beta1"
|
||||
)
|
||||
|
||||
/*
|
||||
Skipper Principles:
|
||||
* if only one backend has a weight, only one backend will get 100% traffic
|
||||
* if two of three or more backends have a weight, only those two should get traffic.
|
||||
* if two backends don't have any weight, it's undefined and right now they get equal amount of traffic.
|
||||
* weights can be int or float, but always treated as a ratio.
|
||||
|
||||
Implementation:
|
||||
* apex Ingress is immutable
|
||||
* new canary Ingress contains two paths for primary and canary service
|
||||
* canary Ingress manages weights on primary & canary service, hence no traffic to apex service
|
||||
|
||||
*/
|
||||
|
||||
const (
|
||||
skipperpredicateAnnotationKey = "zalando.org/skipper-predicate"
|
||||
skipperBackendWeightsAnnotationKey = "zalando.org/backend-weights"
|
||||
canaryPatternf = "%s-canary"
|
||||
canaryRouteWeight = "Weight(100)"
|
||||
canaryRouteDisable = "False()"
|
||||
)
|
||||
|
||||
type SkipperRouter struct {
|
||||
kubeClient kubernetes.Interface
|
||||
logger *zap.SugaredLogger
|
||||
}
|
||||
|
||||
// Reconcile creates or updates the ingresses
|
||||
func (skp *SkipperRouter) Reconcile(canary *flaggerv1.Canary) error {
|
||||
if canary.Spec.IngressRef == nil || canary.Spec.IngressRef.Name == "" {
|
||||
return fmt.Errorf("ingress selector is empty")
|
||||
}
|
||||
|
||||
apexSvcName, primarySvcName, canarySvcName := canary.GetServiceNames()
|
||||
apexIngressName, canaryIngressName := skp.getIngressNames(canary.Spec.IngressRef.Name)
|
||||
|
||||
// retrieving apex ingress
|
||||
apexIngress, err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Get(
|
||||
context.TODO(), apexIngressName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("apexIngress %s.%s get query error: %w", apexIngressName, canary.Namespace, err)
|
||||
}
|
||||
|
||||
// building the canary ingress from apex
|
||||
iClone := apexIngress.DeepCopy()
|
||||
for x := range iClone.Spec.Rules {
|
||||
rule := &iClone.Spec.Rules[x] // ref not value
|
||||
for y := range rule.HTTP.Paths {
|
||||
path := &rule.HTTP.Paths[y] // ref not value
|
||||
if path.Backend.ServiceName == apexSvcName {
|
||||
// flipping to primary service
|
||||
path.Backend.ServiceName = primarySvcName
|
||||
// adding second canary service
|
||||
canaryBackend := path.DeepCopy()
|
||||
canaryBackend.Backend.ServiceName = canarySvcName
|
||||
rule.HTTP.Paths = append(rule.HTTP.Paths, *canaryBackend)
|
||||
}
|
||||
}
|
||||
}
|
||||
if apexIngress.DeepCopy() == iClone {
|
||||
return fmt.Errorf("backend %s not found in ingress %s", apexSvcName, apexIngressName)
|
||||
}
|
||||
|
||||
iClone.Annotations = skp.makeAnnotations(iClone.Annotations, map[string]int{primarySvcName: 100, canarySvcName: 0})
|
||||
iClone.Name = canaryIngressName
|
||||
iClone.Namespace = canary.Namespace
|
||||
iClone.OwnerReferences = []metav1.OwnerReference{
|
||||
*metav1.NewControllerRef(canary, schema.GroupVersionKind{
|
||||
Group: flaggerv1.SchemeGroupVersion.Group,
|
||||
Version: flaggerv1.SchemeGroupVersion.Version,
|
||||
Kind: flaggerv1.CanaryKind,
|
||||
}),
|
||||
}
|
||||
|
||||
// search for existence
|
||||
canaryIngress, err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Get(
|
||||
context.TODO(), canaryIngressName, metav1.GetOptions{})
|
||||
|
||||
// new ingress
|
||||
if errors.IsNotFound(err) {
|
||||
// Let K8s set this. Otherwise K8s API complains with "resourceVersion should not be set on objects to be created"
|
||||
iClone.ObjectMeta.ResourceVersion = ""
|
||||
_, err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Create(context.TODO(), iClone, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingress %s.%s create error: %w", iClone.Name, iClone.Namespace, err)
|
||||
}
|
||||
skp.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
|
||||
Infof("Ingress %s.%s created", iClone.GetName(), canary.Namespace)
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("ingress %s.%s query error: %w", canaryIngressName, canary.Namespace, err)
|
||||
}
|
||||
|
||||
// existant, updating
|
||||
if cmp.Diff(iClone.Spec, canaryIngress.Spec) != "" {
|
||||
ingressClone := canaryIngress.DeepCopy()
|
||||
ingressClone.Spec = iClone.Spec
|
||||
ingressClone.Annotations = iClone.Annotations
|
||||
|
||||
_, err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Update(context.TODO(), ingressClone, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingress %s.%s update error: %w", canaryIngressName, ingressClone.Namespace, err)
|
||||
}
|
||||
skp.logger.With("canary", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
|
||||
Infof("Ingress %s updated", canaryIngressName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (skp *SkipperRouter) GetRoutes(canary *flaggerv1.Canary) (primaryWeight, canaryWeight int, mirrored bool, err error) {
|
||||
_, primarySvcName, canarySvcName := canary.GetServiceNames()
|
||||
|
||||
_, canaryIngressName := skp.getIngressNames(canary.Spec.IngressRef.Name)
|
||||
canaryIngress, err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Get(context.TODO(), canaryIngressName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ingress %s.%s get query error: %w", canaryIngressName, canary.Namespace, err)
|
||||
return
|
||||
}
|
||||
|
||||
weights, err := skp.backendWeights(canaryIngress.Annotations)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ingress %s.%s get backendWeights error: %w", canaryIngressName, canary.Namespace, err)
|
||||
return
|
||||
}
|
||||
var ok bool
|
||||
primaryWeight, ok = weights[primarySvcName]
|
||||
if !ok {
|
||||
err = fmt.Errorf("ingress %s.%s could not get weights[primarySvcName]", canaryIngressName, canary.Namespace)
|
||||
return
|
||||
}
|
||||
canaryWeight, ok = weights[canarySvcName]
|
||||
if !ok {
|
||||
err = fmt.Errorf("ingress %s.%s could not get weights[canarySvcName]", canaryIngressName, canary.Namespace)
|
||||
return
|
||||
}
|
||||
mirrored = false
|
||||
skp.logger.With("GetRoutes", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
|
||||
Debugf("GetRoutes primaryWeight: %d, canaryWeight: %d", primaryWeight, canaryWeight)
|
||||
return
|
||||
}
|
||||
|
||||
func (skp *SkipperRouter) SetRoutes(canary *flaggerv1.Canary, primaryWeight, canaryWeight int, _ bool) (err error) {
|
||||
_, primarySvcName, canarySvcName := canary.GetServiceNames()
|
||||
_, canaryIngressName := skp.getIngressNames(canary.Spec.IngressRef.Name)
|
||||
canaryIngress, err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Get(context.TODO(), canaryIngressName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingress %s.%s get query error: %w", canaryIngressName, canary.Namespace, err)
|
||||
}
|
||||
|
||||
iClone := canaryIngress.DeepCopy()
|
||||
|
||||
// TODO: A/B testing
|
||||
|
||||
// Canary
|
||||
iClone.Annotations = skp.makeAnnotations(iClone.Annotations, map[string]int{
|
||||
primarySvcName: primaryWeight,
|
||||
canarySvcName: canaryWeight,
|
||||
})
|
||||
|
||||
// Disable the canary-ingress route after the canary process
|
||||
if canaryWeight == 0 {
|
||||
iClone.Annotations[skipperpredicateAnnotationKey] = canaryRouteDisable
|
||||
}
|
||||
|
||||
_, err = skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Update(
|
||||
context.TODO(), iClone, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingress %s.%s update error %w", iClone.Name, iClone.Namespace, err)
|
||||
}
|
||||
skp.logger.With("SetRoutes", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
|
||||
Debugf("primaryWeight: %d, canaryWeight: %d", primaryWeight, canaryWeight)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (skp *SkipperRouter) Finalize(canary *flaggerv1.Canary) error {
|
||||
gracePeriodSeconds := int64(2)
|
||||
_, canaryIngressName := skp.getIngressNames(canary.Spec.IngressRef.Name)
|
||||
skp.logger.With("deleteCanaryIngress", fmt.Sprintf("%s.%s", canary.Name, canary.Namespace)).
|
||||
Debugf("Deleting Canary Ingress: %s", canaryIngressName)
|
||||
|
||||
err := skp.kubeClient.NetworkingV1beta1().Ingresses(canary.Namespace).Delete(
|
||||
context.TODO(), canaryIngressName, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriodSeconds})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingress %s.%s unable to remove canary ingress: %w", canaryIngressName, canary.Namespace, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (skp *SkipperRouter) makeAnnotations(annotations map[string]string, backendWeights map[string]int) map[string]string {
|
||||
b, err := json.Marshal(backendWeights)
|
||||
if err != nil {
|
||||
skp.logger.Errorf("Skipper:makeAnnotations: unable to marshal backendWeights %w", err)
|
||||
return annotations
|
||||
}
|
||||
annotations[skipperBackendWeightsAnnotationKey] = string(b)
|
||||
// adding more weight to canary route solves traffic bypassing through apexIngress
|
||||
annotations[skipperpredicateAnnotationKey] = canaryRouteWeight
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
// parse backend-weights annotation if it exists
|
||||
func (skp *SkipperRouter) backendWeights(annotation map[string]string) (backendWeights map[string]int, err error) {
|
||||
backends, ok := annotation[skipperBackendWeightsAnnotationKey]
|
||||
if ok {
|
||||
err = json.Unmarshal([]byte(backends), &backendWeights)
|
||||
} else {
|
||||
err = errors.NewNotFound(schema.GroupResource{Group: "Skipper Canary Ingress", Resource: "Annotation"},
|
||||
skipperBackendWeightsAnnotationKey)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// getIngressNames returns the primary and canary Kubernetes Ingress names
|
||||
func (skp *SkipperRouter) getIngressNames(name string) (apexName, canaryName string) {
|
||||
return name, fmt.Sprintf(canaryPatternf, name)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestSkipperRouter_Reconcile(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
mocks := newFixture(nil)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
mocks func() fixture
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"creating new canary ingress w/ default settings",
|
||||
func() fixture { return mocks },
|
||||
false,
|
||||
}, {
|
||||
"updating existing canary ingress",
|
||||
func() fixture {
|
||||
ti := newTestIngress()
|
||||
ti.Annotations["something"] = "changed"
|
||||
_, err := mocks.kubeClient.NetworkingV1beta1().Ingresses("default").Update(
|
||||
context.TODO(), ti, metav1.UpdateOptions{})
|
||||
assert.NoError(err)
|
||||
return mocks
|
||||
},
|
||||
false,
|
||||
},
|
||||
} {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mocks := tt.mocks()
|
||||
router := &SkipperRouter{
|
||||
kubeClient: mocks.kubeClient,
|
||||
logger: mocks.logger,
|
||||
}
|
||||
assert.NoError(router.Reconcile(mocks.ingressCanary))
|
||||
canaryName := fmt.Sprintf("%s-canary", mocks.ingressCanary.Spec.IngressRef.Name)
|
||||
inCanary, err := router.kubeClient.NetworkingV1beta1().Ingresses("default").Get(
|
||||
context.TODO(), canaryName, metav1.GetOptions{})
|
||||
assert.NoError(err)
|
||||
// test initialisation
|
||||
assert.JSONEq(`{ "podinfo-primary": 100, "podinfo-canary": 0 }`, inCanary.Annotations["zalando.org/backend-weights"])
|
||||
assert.Equal("podinfo-primary", inCanary.Spec.Rules[0].HTTP.Paths[0].Backend.ServiceName, "backend flipped over")
|
||||
assert.Equal("podinfo-canary", inCanary.Spec.Rules[0].HTTP.Paths[1].Backend.ServiceName, "backend flipped over")
|
||||
assert.Len(inCanary.Spec.Rules[0].HTTP.Paths, 2)
|
||||
inApex, err := router.kubeClient.NetworkingV1beta1().Ingresses("default").Get(
|
||||
context.TODO(), mocks.ingressCanary.Spec.IngressRef.Name, metav1.GetOptions{})
|
||||
assert.NoError(err)
|
||||
assert.Equal(inCanary.Spec.Rules[0].HTTP.Paths[0].Backend.ServicePort,
|
||||
inApex.Spec.Rules[0].HTTP.Paths[0].Backend.ServicePort, "canary backend not cloned")
|
||||
assert.Equal(inCanary.Spec.Rules[0].HTTP.Paths[0].Backend.ServicePort,
|
||||
inCanary.Spec.Rules[0].HTTP.Paths[1].Backend.ServicePort, "canary backend not cloned")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkipperRouter_GetSetRoutes(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
mocks := newFixture(nil)
|
||||
|
||||
router := &SkipperRouter{logger: mocks.logger, kubeClient: mocks.kubeClient}
|
||||
assert.NoError(router.Reconcile(mocks.ingressCanary))
|
||||
|
||||
p, c, m, err := router.GetRoutes(mocks.ingressCanary)
|
||||
assert.NoError(err)
|
||||
assert.Equal(100, p)
|
||||
assert.Equal(0, c)
|
||||
assert.Equal(false, m)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
primary, canary int
|
||||
}{
|
||||
{name: "0%", primary: 100, canary: 0},
|
||||
{name: "10%", primary: 90, canary: 10},
|
||||
{name: "20%", primary: 80, canary: 20},
|
||||
{name: "30%", primary: 70, canary: 30},
|
||||
{name: "85%", primary: 15, canary: 85},
|
||||
{name: "100%", primary: 0, canary: 100},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.NoError(router.SetRoutes(mocks.ingressCanary, tt.primary, tt.canary, false))
|
||||
inCanary, err := router.kubeClient.NetworkingV1beta1().Ingresses("default").Get(
|
||||
context.TODO(), fmt.Sprintf("%s-canary", mocks.ingressCanary.Spec.IngressRef.Name), metav1.GetOptions{})
|
||||
assert.NoError(err)
|
||||
assert.JSONEq(fmt.Sprintf(`{"podinfo-primary": %d,"podinfo-canary": %d}`, tt.primary, tt.canary),
|
||||
inCanary.Annotations["zalando.org/backend-weights"])
|
||||
p, c, m, err = router.GetRoutes(mocks.ingressCanary)
|
||||
assert.NoError(err)
|
||||
assert.Equal(tt.primary, p)
|
||||
assert.Equal(tt.canary, c)
|
||||
assert.Equal(false, m)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package version
|
||||
|
||||
var VERSION = "1.0.0"
|
||||
var VERSION = "1.1.0"
|
||||
var REVISION = "unknown"
|
||||
|
||||
Reference in New Issue
Block a user