github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/rollout-service/pkg/service/service_test.go (about)

     1  /*This file is part of kuberpult.
     2  
     3  Kuberpult is free software: you can redistribute it and/or modify
     4  it under the terms of the Expat(MIT) License as published by
     5  the Free Software Foundation.
     6  
     7  Kuberpult is distributed in the hope that it will be useful,
     8  but WITHOUT ANY WARRANTY; without even the implied warranty of
     9  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    10  MIT License for more details.
    11  
    12  You should have received a copy of the MIT License
    13  along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>.
    14  
    15  Copyright 2023 freiheit.com*/
    16  
    17  package service
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"io"
    23  	"testing"
    24  	"time"
    25  
    26  	"github.com/argoproj/argo-cd/v2/pkg/apiclient/application"
    27  	"github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
    28  	"github.com/cenkalti/backoff/v4"
    29  	"github.com/freiheit-com/kuberpult/pkg/setup"
    30  	"github.com/freiheit-com/kuberpult/services/rollout-service/pkg/versions"
    31  	"github.com/google/go-cmp/cmp"
    32  	"github.com/google/go-cmp/cmp/cmpopts"
    33  	"google.golang.org/grpc"
    34  	"google.golang.org/grpc/codes"
    35  	"google.golang.org/grpc/status"
    36  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    37  )
    38  
    39  type step struct {
    40  	Event         *v1alpha1.ApplicationWatchEvent
    41  	WatchErr      error
    42  	RecvErr       error
    43  	CancelContext bool
    44  
    45  	ExpectedEvent *ArgoEvent
    46  }
    47  
    48  func (m *mockApplicationServiceClient) Recv() (*v1alpha1.ApplicationWatchEvent, error) {
    49  	if m.current >= len(m.Steps) {
    50  		return nil, fmt.Errorf("exhausted: %w", io.EOF)
    51  	}
    52  	if m.current != 0 {
    53  		lastReply := m.Steps[m.current-1]
    54  		if lastReply.ExpectedEvent == nil {
    55  
    56  		} else {
    57  			select {
    58  			case lastEvent := <-m.lastEvent:
    59  				if !cmp.Equal(lastReply.ExpectedEvent, lastEvent) {
    60  					m.t.Errorf("step %d did not generate the expected event, diff: %s", m.current-1, cmp.Diff(lastReply.ExpectedEvent, lastEvent))
    61  				}
    62  			case <-time.After(time.Second):
    63  				m.t.Errorf("step %d timed out waiting for event", m.current-1)
    64  			}
    65  		}
    66  	}
    67  	reply := m.Steps[m.current]
    68  	if reply.CancelContext {
    69  		m.cancel()
    70  	}
    71  	m.current = m.current + 1
    72  	return reply.Event, reply.RecvErr
    73  }
    74  
    75  type mockApplicationServiceClient struct {
    76  	Steps     []step
    77  	current   int
    78  	t         *testing.T
    79  	lastEvent chan *ArgoEvent
    80  	cancel    context.CancelFunc
    81  	grpc.ClientStream
    82  }
    83  
    84  func (m *mockApplicationServiceClient) Watch(ctx context.Context, qry *application.ApplicationQuery, opts ...grpc.CallOption) (application.ApplicationService_WatchClient, error) {
    85  	if m.current >= len(m.Steps) {
    86  		return nil, setup.Permanent(fmt.Errorf("exhausted: %w", io.EOF))
    87  	}
    88  	reply := m.Steps[m.current]
    89  	if reply.WatchErr != nil {
    90  		if reply.CancelContext {
    91  			m.cancel()
    92  		}
    93  		m.current = m.current + 1
    94  		return nil, reply.WatchErr
    95  	}
    96  	return m, nil
    97  }
    98  
    99  func (m *mockApplicationServiceClient) testAllConsumed(t *testing.T) {
   100  	if m.current < len(m.Steps) {
   101  		t.Errorf("expected to consume all %d replies, only consumed %d", len(m.Steps), m.current)
   102  	}
   103  }
   104  
   105  // Process implements service.EventProcessor
   106  func (m *mockApplicationServiceClient) ProcessArgoEvent(ctx context.Context, ev ArgoEvent) {
   107  	m.lastEvent <- &ev
   108  }
   109  
   110  type version struct {
   111  	Revision        string
   112  	Environment     string
   113  	Application     string
   114  	Attempt         uint64
   115  	DeployedVersion uint64
   116  	Error           error
   117  }
   118  
   119  type mockVersionClient struct {
   120  	versions.VersionClient
   121  	versions     []version
   122  	attemptCount map[string]uint64
   123  }
   124  
   125  // GetVersion implements versions.VersionClient
   126  func (m *mockVersionClient) GetVersion(ctx context.Context, revision string, environment string, application string) (*versions.VersionInfo, error) {
   127  	if m.attemptCount == nil {
   128  		m.attemptCount = map[string]uint64{}
   129  	}
   130  	key := fmt.Sprintf("%s/%s@%s", environment, application, revision)
   131  	current := m.attemptCount[key]
   132  	m.attemptCount[key] = current + 1
   133  	for _, v := range m.versions {
   134  		if v.Revision == revision && v.Environment == environment && v.Application == application && v.Attempt == current {
   135  			return &versions.VersionInfo{Version: v.DeployedVersion}, v.Error
   136  		}
   137  	}
   138  	return nil, fmt.Errorf("no")
   139  }
   140  
   141  var _ versions.VersionClient = (*mockVersionClient)(nil)
   142  
   143  func TestArgoConection(t *testing.T) {
   144  	tcs := []struct {
   145  		Name          string
   146  		KnownVersions []version
   147  		Steps         []step
   148  
   149  		ExpectedError error
   150  		ExpectedReady bool
   151  	}{
   152  		{
   153  			Name: "stops without error when ctx is closed on Recv call",
   154  			Steps: []step{
   155  				{
   156  					WatchErr:      status.Error(codes.Canceled, "context cancelled"),
   157  					CancelContext: true,
   158  				},
   159  			},
   160  			ExpectedReady: false,
   161  		},
   162  		{
   163  			Name: "does not stop for watch errors",
   164  			Steps: []step{
   165  				{
   166  					WatchErr: fmt.Errorf("no"),
   167  				},
   168  				{
   169  					WatchErr:      status.Error(codes.Canceled, "context cancelled"),
   170  					CancelContext: true,
   171  				},
   172  			},
   173  
   174  			ExpectedReady: false,
   175  		},
   176  		{
   177  			Name: "stops when ctx closes in the watch call",
   178  			Steps: []step{
   179  				{
   180  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   181  					CancelContext: true,
   182  				},
   183  			},
   184  			ExpectedReady: true,
   185  		},
   186  		{
   187  			Name: "retries when Recv fails",
   188  			Steps: []step{
   189  				{
   190  					RecvErr: fmt.Errorf("no"),
   191  				},
   192  				{
   193  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   194  					CancelContext: true,
   195  				},
   196  			},
   197  			ExpectedReady: true,
   198  		},
   199  		{
   200  			Name: "ignore events for applications that were not generated by kuberpult",
   201  			Steps: []step{
   202  				{
   203  					Event: &v1alpha1.ApplicationWatchEvent{
   204  						Type: "ADDED",
   205  						Application: v1alpha1.Application{
   206  							ObjectMeta: metav1.ObjectMeta{
   207  								Name:        "foo",
   208  								Annotations: map[string]string{},
   209  							},
   210  							Spec: v1alpha1.ApplicationSpec{
   211  								Project: "",
   212  							},
   213  							Status: v1alpha1.ApplicationStatus{
   214  								Sync:   v1alpha1.SyncStatus{Revision: "1234"},
   215  								Health: v1alpha1.HealthStatus{},
   216  							},
   217  						},
   218  					},
   219  					// Applications generated by kuberpult have name = "<env>-<name>" and project = "<env>".
   220  					// This application doesn't follow this scheme and must not create an event.
   221  					ExpectedEvent: nil,
   222  				},
   223  				{
   224  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   225  					CancelContext: true,
   226  				},
   227  			},
   228  			ExpectedReady: true,
   229  		},
   230  		{
   231  			Name: "generates events for applications that were generated by kuberpult",
   232  			KnownVersions: []version{
   233  				{
   234  					Revision:        "1234",
   235  					Environment:     "foo",
   236  					Application:     "bar",
   237  					DeployedVersion: 42,
   238  				},
   239  			},
   240  			Steps: []step{
   241  				{
   242  					Event: &v1alpha1.ApplicationWatchEvent{
   243  						Type: "ADDED",
   244  						Application: v1alpha1.Application{
   245  							ObjectMeta: metav1.ObjectMeta{
   246  								Name: "doesntmatter",
   247  								Annotations: map[string]string{
   248  									"com.freiheit.kuberpult/environment": "foo",
   249  									"com.freiheit.kuberpult/application": "bar",
   250  								},
   251  							},
   252  							Spec: v1alpha1.ApplicationSpec{
   253  								Project: "foo",
   254  							},
   255  							Status: v1alpha1.ApplicationStatus{
   256  								Sync:   v1alpha1.SyncStatus{Revision: "1234"},
   257  								Health: v1alpha1.HealthStatus{},
   258  							},
   259  						},
   260  					},
   261  					ExpectedEvent: &ArgoEvent{
   262  						Application: "bar",
   263  						Environment: "foo",
   264  						Version:     &versions.VersionInfo{Version: 42},
   265  					},
   266  				},
   267  				{
   268  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   269  					CancelContext: true,
   270  				},
   271  			},
   272  			ExpectedReady: true,
   273  		},
   274  		{
   275  			Name: "doesnt generate events for deleted",
   276  			KnownVersions: []version{
   277  				{
   278  					Revision:        "1234",
   279  					Environment:     "foo",
   280  					Application:     "bar",
   281  					DeployedVersion: 42,
   282  				},
   283  			},
   284  			Steps: []step{
   285  				{
   286  					Event: &v1alpha1.ApplicationWatchEvent{
   287  						Type: "DELETED",
   288  						Application: v1alpha1.Application{
   289  							ObjectMeta: metav1.ObjectMeta{
   290  								Name: "doesntmatter",
   291  								Annotations: map[string]string{
   292  									"com.freiheit.kuberpult/environment": "foo",
   293  									"com.freiheit.kuberpult/application": "bar",
   294  								},
   295  							},
   296  							Spec: v1alpha1.ApplicationSpec{
   297  								Project: "foo",
   298  							},
   299  							Status: v1alpha1.ApplicationStatus{
   300  								Sync:   v1alpha1.SyncStatus{Revision: "1234"},
   301  								Health: v1alpha1.HealthStatus{},
   302  							},
   303  						},
   304  					},
   305  					ExpectedEvent: &ArgoEvent{
   306  						Application: "bar",
   307  						Environment: "foo",
   308  						Version:     &versions.VersionInfo{Version: 0},
   309  					},
   310  				},
   311  				{
   312  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   313  					CancelContext: true,
   314  				},
   315  			},
   316  			ExpectedReady: true,
   317  		},
   318  		{
   319  			Name: "recovers from errors",
   320  			KnownVersions: []version{
   321  				{
   322  					Revision:    "1234",
   323  					Environment: "foo",
   324  					Application: "bar",
   325  					Attempt:     0,
   326  					Error:       fmt.Errorf("no"),
   327  				},
   328  				{
   329  					Revision:        "1234",
   330  					Environment:     "foo",
   331  					Application:     "bar",
   332  					Attempt:         1,
   333  					DeployedVersion: 1,
   334  				},
   335  			},
   336  			Steps: []step{
   337  				{
   338  					Event: &v1alpha1.ApplicationWatchEvent{
   339  						Type: "ADDED",
   340  						Application: v1alpha1.Application{
   341  							ObjectMeta: metav1.ObjectMeta{
   342  								Name: "doesntmatter",
   343  								Annotations: map[string]string{
   344  									"com.freiheit.kuberpult/environment": "foo",
   345  									"com.freiheit.kuberpult/application": "bar",
   346  								},
   347  							},
   348  							Spec: v1alpha1.ApplicationSpec{
   349  								Project: "foo",
   350  							},
   351  							Status: v1alpha1.ApplicationStatus{
   352  								Sync:   v1alpha1.SyncStatus{Revision: "1234"},
   353  								Health: v1alpha1.HealthStatus{},
   354  							},
   355  						},
   356  					},
   357  					ExpectedEvent: &ArgoEvent{
   358  						Application: "bar",
   359  						Environment: "foo",
   360  						Version:     &versions.VersionInfo{Version: 1},
   361  					},
   362  				},
   363  				{
   364  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   365  					CancelContext: true,
   366  				},
   367  			},
   368  			ExpectedReady: true,
   369  		},
   370  	}
   371  	for _, tc := range tcs {
   372  		tc := tc
   373  		t.Run(tc.Name, func(t *testing.T) {
   374  			t.Parallel()
   375  			ctx, cancel := context.WithCancel(context.Background())
   376  			as := mockApplicationServiceClient{
   377  				Steps:     tc.Steps,
   378  				cancel:    cancel,
   379  				t:         t,
   380  				lastEvent: make(chan *ArgoEvent, 10),
   381  			}
   382  			hlth := &setup.HealthServer{}
   383  			hlth.BackOffFactory = func() backoff.BackOff { return backoff.NewConstantBackOff(0) }
   384  			dispatcher := NewDispatcher(&as, &mockVersionClient{versions: tc.KnownVersions})
   385  			go dispatcher.Work(ctx, hlth.Reporter("dispatcher"))
   386  			err := ConsumeEvents(ctx, &as, dispatcher, hlth.Reporter("consume"))
   387  			if diff := cmp.Diff(tc.ExpectedError, err, cmpopts.EquateErrors()); diff != "" {
   388  				t.Errorf("error mismatch (-want, +got):\n%s", diff)
   389  			}
   390  			ready := hlth.IsReady("consume")
   391  			if tc.ExpectedReady != ready {
   392  				t.Errorf("expected ready to be %t but got %t", tc.ExpectedReady, ready)
   393  			}
   394  			as.testAllConsumed(t)
   395  		})
   396  	}
   397  }