diff --git a/artifacts/flagger/crd.yaml b/artifacts/flagger/crd.yaml index 68522b86..7b318f6a 100644 --- a/artifacts/flagger/crd.yaml +++ b/artifacts/flagger/crd.yaml @@ -909,6 +909,18 @@ spec: type: object additionalProperties: type: string + unmanagedMetadata: + description: UnmanagedMetadata is a list of metadata keys that should be ignored by Flagger. + type: object + properties: + annotations: + type: array + items: + type: string + labels: + type: array + items: + type: string skipAnalysis: description: Skip analysis and promote canary type: boolean diff --git a/charts/flagger/crds/crd.yaml b/charts/flagger/crds/crd.yaml index 68522b86..7b318f6a 100644 --- a/charts/flagger/crds/crd.yaml +++ b/charts/flagger/crds/crd.yaml @@ -909,6 +909,18 @@ spec: type: object additionalProperties: type: string + unmanagedMetadata: + description: UnmanagedMetadata is a list of metadata keys that should be ignored by Flagger. + type: object + properties: + annotations: + type: array + items: + type: string + labels: + type: array + items: + type: string skipAnalysis: description: Skip analysis and promote canary type: boolean diff --git a/docs/gitbook/usage/how-it-works.md b/docs/gitbook/usage/how-it-works.md index 942db5ca..31f109dd 100644 --- a/docs/gitbook/usage/how-it-works.md +++ b/docs/gitbook/usage/how-it-works.md @@ -206,6 +206,10 @@ Note that the `apex` annotations are added to both the generated Kubernetes Serv generated service mesh/ingress object. This allows using external-dns with Istio `VirtualServices` and `TraefikServices`. Beware of configuration conflicts [here](../faq.md#ExternalDNS). +Note that if any annotations or labels are added that are not specified here, +Flagger will remove them during reconciliation. To specify metadata +that should be ignored by Flagger, configure `unmanagedMetadata`. + If you want for the generated Kubernetes ClusterIP services to be [headless](https://kubernetes.io/docs/concepts/services-networking/service/#headless-services), then set `service.headless` to true. diff --git a/kustomize/base/flagger/crd.yaml b/kustomize/base/flagger/crd.yaml index 68522b86..7b318f6a 100644 --- a/kustomize/base/flagger/crd.yaml +++ b/kustomize/base/flagger/crd.yaml @@ -909,6 +909,18 @@ spec: type: object additionalProperties: type: string + unmanagedMetadata: + description: UnmanagedMetadata is a list of metadata keys that should be ignored by Flagger. + type: object + properties: + annotations: + type: array + items: + type: string + labels: + type: array + items: + type: string skipAnalysis: description: Skip analysis and promote canary type: boolean diff --git a/pkg/apis/flagger/v1beta1/canary.go b/pkg/apis/flagger/v1beta1/canary.go index 6998546e..f3af0d59 100644 --- a/pkg/apis/flagger/v1beta1/canary.go +++ b/pkg/apis/flagger/v1beta1/canary.go @@ -223,6 +223,17 @@ type CanaryService struct { // Canary is the metadata to add to the canary service // +optional Canary *CustomMetadata `json:"canary,omitempty"` + + // UnmanagedMetadata is a list of metadata keys that should be ignored by Flagger. + // Flagger will not add, remove or change the value of these annotations. + // +optional + UnmanagedMetadata *UnmanagedMetadata `json:"unmanagedMetadata,omitempty"` +} + +// UnmanagedMetadata is a list of metadata keys that should be ignored by Flagger. +type UnmanagedMetadata struct { + Annotations []string `json:"annotations,omitempty"` + Labels []string `json:"labels,omitempty"` } // CanaryAnalysis is used to describe how the analysis should be done diff --git a/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go b/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go index a9a325d7..1603817d 100644 --- a/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/flagger/v1beta1/zz_generated.deepcopy.go @@ -452,6 +452,11 @@ func (in *CanaryService) DeepCopyInto(out *CanaryService) { *out = new(CustomMetadata) (*in).DeepCopyInto(*out) } + if in.UnmanagedMetadata != nil { + in, out := &in.UnmanagedMetadata, &out.UnmanagedMetadata + *out = new(UnmanagedMetadata) + (*in).DeepCopyInto(*out) + } return } @@ -926,3 +931,29 @@ func (in *SessionAffinity) DeepCopy() *SessionAffinity { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UnmanagedMetadata) DeepCopyInto(out *UnmanagedMetadata) { + *out = *in + if in.Annotations != nil { + in, out := &in.Annotations, &out.Annotations + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UnmanagedMetadata. +func (in *UnmanagedMetadata) DeepCopy() *UnmanagedMetadata { + if in == nil { + return nil + } + out := new(UnmanagedMetadata) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/router/kubernetes_default.go b/pkg/router/kubernetes_default.go index 184b6233..ffdd8059 100644 --- a/pkg/router/kubernetes_default.go +++ b/pkg/router/kubernetes_default.go @@ -213,12 +213,42 @@ func (c *KubernetesDefaultRouter) reconcileService(canary *flaggerv1.Canary, nam if svc.ObjectMeta.Annotations == nil { svc.ObjectMeta.Annotations = make(map[string]string) } - if diff := cmp.Diff(filterMetadata(metadata.Annotations), svc.ObjectMeta.Annotations); diff != "" { - svcClone.ObjectMeta.Annotations = filterMetadata(metadata.Annotations) + + // Preserve unmanaged metadata + unmanagedAnnotations := make(map[string]string) + if canary.Spec.Service.UnmanagedMetadata != nil { + for _, key := range canary.Spec.Service.UnmanagedMetadata.Annotations { + if value, ok := svc.ObjectMeta.Annotations[key]; ok { + unmanagedAnnotations[key] = value + } + } + } + + unmanagedLabels := make(map[string]string) + if canary.Spec.Service.UnmanagedMetadata != nil { + for _, key := range canary.Spec.Service.UnmanagedMetadata.Labels { + if value, ok := svc.ObjectMeta.Labels[key]; ok { + unmanagedLabels[key] = value + } + } + } + + newAnnotations := filterMetadata(metadata.Annotations) + for k, v := range unmanagedAnnotations { + newAnnotations[k] = v + } + + newLabels := metadata.Labels + for k, v := range unmanagedLabels { + newLabels[k] = v + } + + if diff := cmp.Diff(newAnnotations, svc.ObjectMeta.Annotations); diff != "" { + svcClone.ObjectMeta.Annotations = newAnnotations updateService = true } - if diff := cmp.Diff(metadata.Labels, svc.ObjectMeta.Labels); diff != "" { - svcClone.ObjectMeta.Labels = metadata.Labels + if diff := cmp.Diff(newLabels, svc.ObjectMeta.Labels); diff != "" { + svcClone.ObjectMeta.Labels = newLabels updateService = true } } diff --git a/pkg/router/kubernetes_default_test.go b/pkg/router/kubernetes_default_test.go index b428c4a9..e252c877 100644 --- a/pkg/router/kubernetes_default_test.go +++ b/pkg/router/kubernetes_default_test.go @@ -451,3 +451,147 @@ func TestServiceRouter_ReconcileMetadata(t *testing.T) { assert.Equal(t, "test1", apexSvc.Labels["test"]) assert.Equal(t, "podinfo", apexSvc.Labels["app"]) } + +func TestServiceRouter_UnmanagedAnnotations(t *testing.T) { + mocks := newFixture(nil) + router := &KubernetesDefaultRouter{ + kubeClient: mocks.kubeClient, + flaggerClient: mocks.flaggerClient, + logger: mocks.logger, + labelSelector: "app", + } + + mocks.canary.Spec.Service.Apex = &flaggerv1.CustomMetadata{ + Annotations: map[string]string{"test": "expectedvalue"}, + } + mocks.canary.Spec.Service.UnmanagedMetadata = &flaggerv1.UnmanagedMetadata{ + Annotations: []string{"unmanaged"}, + } + + err := router.Initialize(mocks.canary) + require.NoError(t, err) + + err = router.Reconcile(mocks.canary) + require.NoError(t, err) + + apexSvc, err := mocks.kubeClient.CoreV1().Services("default").Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + clone := apexSvc.DeepCopy() + clone.Annotations["unmanaged"] = "true" + clone.Annotations["test"] = "newvalue" + clone.Annotations["removable"] = "true" + _, err = mocks.kubeClient.CoreV1().Services("default").Update(context.TODO(), clone, metav1.UpdateOptions{}) + require.NoError(t, err) + + err = router.Reconcile(mocks.canary) + require.NoError(t, err) + + apexSvc, err = mocks.kubeClient.CoreV1().Services("default").Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + assert.Equal(t, "expectedvalue", apexSvc.Annotations["test"]) + assert.Equal(t, "true", apexSvc.Annotations["unmanaged"]) + _, ok := apexSvc.Annotations["removable"] + assert.False(t, ok) +} + +func TestServiceRouter_UnmanagedLabels(t *testing.T) { + mocks := newFixture(nil) + router := &KubernetesDefaultRouter{ + kubeClient: mocks.kubeClient, + flaggerClient: mocks.flaggerClient, + logger: mocks.logger, + labelSelector: "app", + } + + mocks.canary.Spec.Service.Apex = &flaggerv1.CustomMetadata{ + Labels: map[string]string{"test": "expectedvalue"}, + } + mocks.canary.Spec.Service.UnmanagedMetadata = &flaggerv1.UnmanagedMetadata{ + Labels: []string{"unmanaged"}, + } + + err := router.Initialize(mocks.canary) + require.NoError(t, err) + + err = router.Reconcile(mocks.canary) + require.NoError(t, err) + + apexSvc, err := mocks.kubeClient.CoreV1().Services("default").Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + clone := apexSvc.DeepCopy() + clone.Labels["unmanaged"] = "true" + clone.Labels["test"] = "newvalue" + clone.Labels["removable"] = "true" + _, err = mocks.kubeClient.CoreV1().Services("default").Update(context.TODO(), clone, metav1.UpdateOptions{}) + require.NoError(t, err) + + err = router.Reconcile(mocks.canary) + require.NoError(t, err) + + apexSvc, err = mocks.kubeClient.CoreV1().Services("default").Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + assert.Equal(t, "expectedvalue", apexSvc.Labels["test"]) + assert.Equal(t, "true", apexSvc.Labels["unmanaged"]) + _, ok := apexSvc.Labels["removable"] + assert.False(t, ok) +} + +func TestServiceRouter_UnmanagedMetadata_AnnotationsAndLabels(t *testing.T) { + mocks := newFixture(nil) + router := &KubernetesDefaultRouter{ + kubeClient: mocks.kubeClient, + flaggerClient: mocks.flaggerClient, + logger: mocks.logger, + labelSelector: "app", + } + + mocks.canary.Spec.Service.Apex = &flaggerv1.CustomMetadata{ + Annotations: map[string]string{"test": "expectedvalue"}, + Labels: map[string]string{"test": "expectedvalue"}, + } + mocks.canary.Spec.Service.UnmanagedMetadata = &flaggerv1.UnmanagedMetadata{ + Annotations: []string{"unmanaged"}, + Labels: []string{"unmanaged"}, + } + + err := router.Initialize(mocks.canary) + require.NoError(t, err) + + err = router.Reconcile(mocks.canary) + require.NoError(t, err) + + apexSvc, err := mocks.kubeClient.CoreV1().Services("default").Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + clone := apexSvc.DeepCopy() + clone.Annotations["unmanaged"] = "true" + clone.Annotations["test"] = "newvalue" + clone.Annotations["removable"] = "true" + clone.Labels["unmanaged"] = "true" + clone.Labels["test"] = "newvalue" + clone.Labels["removable"] = "true" + _, err = mocks.kubeClient.CoreV1().Services("default").Update(context.TODO(), clone, metav1.UpdateOptions{}) + require.NoError(t, err) + + err = router.Reconcile(mocks.canary) + require.NoError(t, err) + + apexSvc, err = mocks.kubeClient.CoreV1().Services("default").Get(context.TODO(), "podinfo", metav1.GetOptions{}) + require.NoError(t, err) + + // The result should be that the canary spec annotations should be changed back to configured canary value, + // and the unmanaged annotation should remain unchanged. + assert.Equal(t, "expectedvalue", apexSvc.Annotations["test"]) + assert.Equal(t, "true", apexSvc.Annotations["unmanaged"]) + _, ok := apexSvc.Annotations["removable"] + assert.False(t, ok) + + assert.Equal(t, "expectedvalue", apexSvc.Labels["test"]) + assert.Equal(t, "true", apexSvc.Labels["unmanaged"]) + _, ok = apexSvc.Labels["removable"] + assert.False(t, ok) +}