Add Canary Webhook checksum.

This adds a new Checksum field to the canary webhook body, which is a
hash of the LastAppliedSpec and TrackedConfigs.

This can be used to identify the rollout of a specific configuration,
and differentiate between webhooks being sent for different
configuration and deployment versions.

Signed-off-by: Kevin McDermott <kevin@weave.works>
This commit is contained in:
Kevin McDermott
2023-09-08 14:09:17 +01:00
committed by Kevin McDermott
parent 788e692e90
commit 56b6339f8c
8 changed files with 178 additions and 37 deletions

View File

@@ -83,16 +83,19 @@ Webhook payload (HTTP POST):
```javascript
{
"name": "podinfo",
"namespace": "test",
"phase": "Progressing",
"metadata": {
"test": "all",
"token": "16688eb5e9f289f1991c"
}
"name": "podinfo",
"namespace": "test",
"phase": "Progressing",
"checksum": "85d557f47b",
"metadata": {
"test": "all",
"token": "16688eb5e9f289f1991c"
}
}
```
The checksum field is hashed from the TrackedConfigs and LastAppliedSpec of the Canary, it can be used to identify a Canary for a specific configuration of the deployed resources.
Response status codes:
* 200-202 - advance canary by increasing the traffic weight
@@ -107,6 +110,7 @@ Event payload (HTTP POST):
"name": "string (canary name)",
"namespace": "string (canary namespace)",
"phase": "string (canary phase)",
"checksum": "string (canary checksum"),
"metadata": {
"eventMessage": "string (canary event message)",
"eventType": "string (canary event type)",

View File

@@ -403,6 +403,11 @@ type CanaryWebhookPayload struct {
// Phase of the canary analysis
Phase CanaryPhase `json:"phase"`
// Hash from the TrackedConfigs and LastAppliedSpec of the Canary.
// Can be used to identify a Canary for a specific configuration of the
// deployed resources.
Checksum string `json:"checksum"`
// Metadata (key-value pairs) for this webhook
Metadata map[string]string `json:"metadata,omitempty"`
}

View File

@@ -34,7 +34,7 @@ func hasSpecChanged(cd *flaggerv1.Canary, spec interface{}) (bool, error) {
return true, nil
}
newHash := computeHash(spec)
newHash := ComputeHash(spec)
// do not trigger a canary deployment on manual rollback
if cd.Status.LastPromotedSpec == newHash {
@@ -48,10 +48,10 @@ func hasSpecChanged(cd *flaggerv1.Canary, spec interface{}) (bool, error) {
return false, nil
}
// computeHash returns a hash value calculated from a spec using the spew library
// ComputeHash returns a hash value calculated from a spec using the spew library
// which follows pointers and prints actual values of the nested objects
// ensuring the hash does not change when a pointer changes.
func computeHash(spec interface{}) string {
func ComputeHash(spec interface{}) string {
hasher := fnv.New32a()
printer := spew.ConfigState{
Indent: " ",

View File

@@ -30,7 +30,7 @@ import (
)
func syncCanaryStatus(flaggerClient clientset.Interface, cd *flaggerv1.Canary, status flaggerv1.CanaryStatus, canaryResource interface{}, setAll func(cdCopy *flaggerv1.Canary)) error {
hash := computeHash(canaryResource)
hash := ComputeHash(canaryResource)
firstTry := true
name, ns := cd.GetName(), cd.GetNamespace()

View File

@@ -727,7 +727,7 @@ func (c *Controller) runAnalysis(canary *flaggerv1.Canary) bool {
// run external checks
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == "" || webhook.Type == flaggerv1.RolloutHook {
err := CallWebhook(canary.Name, canary.Namespace, flaggerv1.CanaryPhaseProgressing, webhook)
err := CallWebhook(*canary, flaggerv1.CanaryPhaseProgressing, webhook)
if err != nil {
c.recordEventWarningf(canary, "Halt %s.%s advancement external check %s failed %v",
canary.Name, canary.Namespace, webhook.Name, err)

View File

@@ -26,7 +26,7 @@ import (
func (c *Controller) runConfirmTrafficIncreaseHooks(canary *flaggerv1.Canary) bool {
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == flaggerv1.ConfirmTrafficIncreaseHook {
err := CallWebhook(canary.Name, canary.Namespace, flaggerv1.CanaryPhaseProgressing, webhook)
err := CallWebhook(*canary, flaggerv1.CanaryPhaseProgressing, webhook)
if err != nil {
c.recordEventWarningf(canary, "Halt %s.%s advancement waiting for traffic increase approval %s",
canary.Name, canary.Namespace, webhook.Name)
@@ -44,7 +44,7 @@ func (c *Controller) runConfirmTrafficIncreaseHooks(canary *flaggerv1.Canary) bo
func (c *Controller) runConfirmRolloutHooks(canary *flaggerv1.Canary, canaryController canary.Controller) bool {
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == flaggerv1.ConfirmRolloutHook {
err := CallWebhook(canary.Name, canary.Namespace, canary.Status.Phase, webhook)
err := CallWebhook(*canary, canary.Status.Phase, webhook)
if err != nil {
if canary.Status.Phase != flaggerv1.CanaryPhaseWaiting {
if err := canaryController.SetStatusPhase(canary, flaggerv1.CanaryPhaseWaiting); err != nil {
@@ -67,7 +67,7 @@ func (c *Controller) runConfirmRolloutHooks(canary *flaggerv1.Canary, canaryCont
func (c *Controller) runConfirmPromotionHooks(canary *flaggerv1.Canary, canaryController canary.Controller) bool {
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == flaggerv1.ConfirmPromotionHook {
err := CallWebhook(canary.Name, canary.Namespace, flaggerv1.CanaryPhaseProgressing, webhook)
err := CallWebhook(*canary, flaggerv1.CanaryPhaseProgressing, webhook)
if err != nil {
if canary.Status.Phase != flaggerv1.CanaryPhaseWaitingPromotion {
if err := canaryController.SetStatusPhase(canary, flaggerv1.CanaryPhaseWaitingPromotion); err != nil {
@@ -95,7 +95,7 @@ func (c *Controller) runConfirmPromotionHooks(canary *flaggerv1.Canary, canaryCo
func (c *Controller) runPreRolloutHooks(canary *flaggerv1.Canary) bool {
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == flaggerv1.PreRolloutHook {
err := CallWebhook(canary.Name, canary.Namespace, flaggerv1.CanaryPhaseProgressing, webhook)
err := CallWebhook(*canary, flaggerv1.CanaryPhaseProgressing, webhook)
if err != nil {
c.recordEventWarningf(canary, "Halt %s.%s advancement pre-rollout check %s failed %v",
canary.Name, canary.Namespace, webhook.Name, err)
@@ -111,7 +111,7 @@ func (c *Controller) runPreRolloutHooks(canary *flaggerv1.Canary) bool {
func (c *Controller) runPostRolloutHooks(canary *flaggerv1.Canary, phase flaggerv1.CanaryPhase) bool {
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == flaggerv1.PostRolloutHook {
err := CallWebhook(canary.Name, canary.Namespace, phase, webhook)
err := CallWebhook(*canary, phase, webhook)
if err != nil {
c.recordEventWarningf(canary, "Post-rollout hook %s failed %v", webhook.Name, err)
return false
@@ -126,7 +126,7 @@ func (c *Controller) runPostRolloutHooks(canary *flaggerv1.Canary, phase flagger
func (c *Controller) runRollbackHooks(canary *flaggerv1.Canary, phase flaggerv1.CanaryPhase) bool {
for _, webhook := range canary.GetAnalysis().Webhooks {
if webhook.Type == flaggerv1.RollbackHook {
err := CallWebhook(canary.Name, canary.Namespace, phase, webhook)
err := CallWebhook(*canary, phase, webhook)
if err != nil {
c.recordEventInfof(canary, "Rollback hook %s not signaling a rollback", webhook.Name)
} else {

View File

@@ -29,6 +29,7 @@ import (
"time"
flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
"github.com/fluxcd/flagger/pkg/canary"
)
func callWebhook(webhook string, payload interface{}, timeout string) error {
@@ -81,11 +82,12 @@ func callWebhook(webhook string, payload interface{}, timeout string) error {
// CallWebhook does a HTTP POST to an external service and
// returns an error if the response status code is non-2xx
func CallWebhook(name string, namespace string, phase flaggerv1.CanaryPhase, w flaggerv1.CanaryWebhook) error {
func CallWebhook(canary flaggerv1.Canary, phase flaggerv1.CanaryPhase, w flaggerv1.CanaryWebhook) error {
payload := flaggerv1.CanaryWebhookPayload{
Name: name,
Namespace: namespace,
Name: canary.Name,
Namespace: canary.Namespace,
Phase: phase,
Checksum: canaryChecksum(canary),
}
if w.Metadata != nil {
@@ -106,6 +108,7 @@ func CallEventWebhook(r *flaggerv1.Canary, w flaggerv1.CanaryWebhook, message, e
Name: r.Name,
Namespace: r.Namespace,
Phase: r.Status.Phase,
Checksum: canaryChecksum(*r),
Metadata: map[string]string{
"eventMessage": message,
"eventType": eventtype,
@@ -123,3 +126,15 @@ func CallEventWebhook(r *flaggerv1.Canary, w flaggerv1.CanaryWebhook, message, e
}
return callWebhook(w.URL, payload, "5s")
}
func canaryChecksum(c flaggerv1.Canary) string {
canaryFields := struct {
TrackedConfigs *map[string]string
LastAppliedSpec string
}{
c.Status.TrackedConfigs,
c.Status.LastAppliedSpec,
}
return canary.ComputeHash(canaryFields)
}

View File

@@ -26,25 +26,71 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
flaggerv1 "github.com/fluxcd/flagger/pkg/apis/flagger/v1beta1"
)
type testRequest struct {
path string
body map[string]any
header http.Header
}
func TestCallWebhook(t *testing.T) {
requests := []testRequest{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
var body map[string]any
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
requests = append(requests, testRequest{
path: r.URL.Path,
body: body,
})
}))
defer ts.Close()
hook := flaggerv1.CanaryWebhook{
Name: "validation",
URL: ts.URL,
URL: ts.URL + "/testing",
Timeout: "10s",
Metadata: &map[string]string{"key1": "val1"},
}
err := CallWebhook("podinfo", v1.NamespaceDefault, flaggerv1.CanaryPhaseProgressing, hook)
canary := flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "podinfo", Namespace: corev1.NamespaceDefault,
},
Status: flaggerv1.CanaryStatus{
TrackedConfigs: &map[string]string{
"test-config-map": "484637c76acaa7c6",
},
LastAppliedSpec: "4cb74184589",
},
}
err := CallWebhook(canary,
flaggerv1.CanaryPhaseProgressing, hook)
require.NoError(t, err)
want := []testRequest{
{
path: "/testing",
body: map[string]any{
"name": "podinfo",
"namespace": "default",
"phase": "Progressing",
"checksum": canaryChecksum(canary),
"metadata": map[string]any{
"key1": "val1",
},
},
},
}
require.EqualValues(t, want, requests)
}
func TestCallWebhook_StatusCode(t *testing.T) {
@@ -57,16 +103,30 @@ func TestCallWebhook_StatusCode(t *testing.T) {
URL: ts.URL,
}
err := CallWebhook("podinfo", v1.NamespaceDefault, flaggerv1.CanaryPhaseProgressing, hook)
err := CallWebhook(
flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "podinfo", Namespace: corev1.NamespaceDefault}},
flaggerv1.CanaryPhaseProgressing, hook)
assert.Error(t, err)
}
func TestCallEventWebhook(t *testing.T) {
canaryName := "podinfo"
canaryNamespace := v1.NamespaceDefault
canaryNamespace := corev1.NamespaceDefault
canaryMessage := fmt.Sprintf("Starting canary analysis for %s.%s", canaryName, canaryNamespace)
canaryEventType := corev1.EventTypeNormal
canary := &flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: canaryName,
Namespace: canaryNamespace,
},
Status: flaggerv1.CanaryStatus{
Phase: flaggerv1.CanaryPhaseProgressing,
},
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d := json.NewDecoder(r.Body)
@@ -98,22 +158,19 @@ func TestCallEventWebhook(t *testing.T) {
return
}
if payload.Checksum != canaryChecksum(*canary) {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusAccepted)
}))
defer ts.Close()
hook := flaggerv1.CanaryWebhook{
Name: "event",
URL: ts.URL,
}
canary := &flaggerv1.Canary{
ObjectMeta: v1.ObjectMeta{
Name: canaryName,
Namespace: canaryNamespace,
},
Status: flaggerv1.CanaryStatus{
Phase: flaggerv1.CanaryPhaseProgressing,
},
}
err := CallEventWebhook(canary, hook, canaryMessage, canaryEventType)
require.NoError(t, err)
@@ -121,7 +178,7 @@ func TestCallEventWebhook(t *testing.T) {
func TestCallEventWebhookStatusCode(t *testing.T) {
canaryName := "podinfo"
canaryNamespace := v1.NamespaceDefault
canaryNamespace := corev1.NamespaceDefault
canaryMessage := fmt.Sprintf("Starting canary analysis for %s.%s", canaryName, canaryNamespace)
canaryEventType := corev1.EventTypeNormal
@@ -134,7 +191,7 @@ func TestCallEventWebhookStatusCode(t *testing.T) {
URL: ts.URL,
}
canary := &flaggerv1.Canary{
ObjectMeta: v1.ObjectMeta{
ObjectMeta: metav1.ObjectMeta{
Name: canaryName,
Namespace: canaryNamespace,
},
@@ -146,3 +203,63 @@ func TestCallEventWebhookStatusCode(t *testing.T) {
err := CallEventWebhook(canary, hook, canaryMessage, canaryEventType)
assert.Error(t, err)
}
func TestCanaryChecksum(t *testing.T) {
canary1 := flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "podinfo", Namespace: corev1.NamespaceDefault},
Status: flaggerv1.CanaryStatus{
TrackedConfigs: &map[string]string{
"test-config-map": "484637c76acaa7c6",
},
LastAppliedSpec: "5f56684589",
},
}
canary1sum := canaryChecksum(canary1)
canary2 := flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "podinfo", Namespace: corev1.NamespaceDefault},
Status: flaggerv1.CanaryStatus{
TrackedConfigs: &map[string]string{
"test-config-map": "9fc3a7c76acaa7c6",
},
LastAppliedSpec: "5f56684589",
},
}
canary2sum := canaryChecksum(canary2)
canary3 := flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "podinfo",
Namespace: corev1.NamespaceDefault,
},
Status: flaggerv1.CanaryStatus{
TrackedConfigs: &map[string]string{
"test-config-map": "484637c76acaa7c6",
},
LastAppliedSpec: "4cb74184589",
},
}
canary3sum := canaryChecksum(canary3)
canary4 := flaggerv1.Canary{
ObjectMeta: metav1.ObjectMeta{
Name: "podinfo",
Namespace: corev1.NamespaceDefault,
},
Status: flaggerv1.CanaryStatus{
TrackedConfigs: nil,
LastAppliedSpec: "4cb74184589",
},
}
canary4sum := canaryChecksum(canary4)
require.Equal(t, canary1sum, canaryChecksum(canary1))
require.NotEqual(t, canary1sum, canary2sum)
require.NotEqual(t, canary2sum, canary3sum)
require.NotEqual(t, canary3sum, canary1sum)
require.NotEqual(t, canary4sum, canary1sum)
}