github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/rollout-service/pkg/versions/versions_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 versions
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"io"
    23  	"testing"
    24  	"time"
    25  
    26  	"github.com/cenkalti/backoff/v4"
    27  	api "github.com/freiheit-com/kuberpult/pkg/api/v1"
    28  	"github.com/freiheit-com/kuberpult/pkg/setup"
    29  	"github.com/google/go-cmp/cmp"
    30  	grpc "google.golang.org/grpc"
    31  	"google.golang.org/grpc/codes"
    32  	"google.golang.org/grpc/metadata"
    33  	"google.golang.org/grpc/status"
    34  	"google.golang.org/protobuf/types/known/timestamppb"
    35  )
    36  
    37  type step struct {
    38  	Overview      *api.GetOverviewResponse
    39  	ConnectErr    error
    40  	RecvErr       error
    41  	CancelContext bool
    42  
    43  	ExpectReady    bool
    44  	ExpectedEvents []KuberpultEvent
    45  }
    46  
    47  type expectedVersion struct {
    48  	Revision         string
    49  	Environment      string
    50  	Application      string
    51  	DeployedVersion  uint64
    52  	DeployTime       time.Time
    53  	SourceCommitId   string
    54  	OverviewMetadata metadata.MD
    55  	VersionMetadata  metadata.MD
    56  	IsProduction     bool
    57  }
    58  
    59  type mockOverviewStreamMessage struct {
    60  	Overview     *api.GetOverviewResponse
    61  	Error        error
    62  	ConnectError error
    63  }
    64  
    65  type mockOverviewClient struct {
    66  	grpc.ClientStream
    67  	Responses    map[string]*api.GetOverviewResponse
    68  	LastMetadata metadata.MD
    69  	StartStep    chan struct{}
    70  	Steps        chan step
    71  	savedStep    *step
    72  	current      int
    73  }
    74  
    75  // GetOverview implements api.OverviewServiceClient
    76  func (m *mockOverviewClient) GetOverview(ctx context.Context, in *api.GetOverviewRequest, opts ...grpc.CallOption) (*api.GetOverviewResponse, error) {
    77  	m.LastMetadata, _ = metadata.FromOutgoingContext(ctx)
    78  	if resp := m.Responses[in.GitRevision]; resp != nil {
    79  		return resp, nil
    80  	}
    81  	return nil, status.Error(codes.Unknown, "no")
    82  }
    83  
    84  // StreamOverview implements api.OverviewServiceClient
    85  func (m *mockOverviewClient) StreamOverview(ctx context.Context, in *api.GetOverviewRequest, opts ...grpc.CallOption) (api.OverviewService_StreamOverviewClient, error) {
    86  	m.StartStep <- struct{}{}
    87  	reply, ok := <-m.Steps
    88  	if !ok {
    89  		return nil, fmt.Errorf("exhausted: %w", io.EOF)
    90  	}
    91  	if reply.ConnectErr != nil {
    92  		return nil, reply.ConnectErr
    93  	}
    94  	m.savedStep = &reply
    95  	return m, nil
    96  }
    97  
    98  func (m *mockOverviewClient) Recv() (*api.GetOverviewResponse, error) {
    99  	var reply step
   100  	var ok bool
   101  	if m.savedStep != nil {
   102  		reply = *m.savedStep
   103  		m.savedStep = nil
   104  		ok = true
   105  	} else {
   106  		m.StartStep <- struct{}{}
   107  		reply, ok = <-m.Steps
   108  	}
   109  	if !ok {
   110  		return nil, fmt.Errorf("exhausted: %w", io.EOF)
   111  	}
   112  	return reply.Overview, reply.RecvErr
   113  }
   114  
   115  var _ api.OverviewServiceClient = (*mockOverviewClient)(nil)
   116  
   117  type mockVersionResponse struct {
   118  	response *api.GetVersionResponse
   119  	err      error
   120  }
   121  type mockVersionClient struct {
   122  	responses    map[string]mockVersionResponse
   123  	LastMetadata metadata.MD
   124  }
   125  
   126  func (m *mockVersionClient) GetVersion(ctx context.Context, in *api.GetVersionRequest, opts ...grpc.CallOption) (*api.GetVersionResponse, error) {
   127  	m.LastMetadata, _ = metadata.FromOutgoingContext(ctx)
   128  	key := fmt.Sprintf("%s/%s@%s", in.Environment, in.Application, in.GitRevision)
   129  	res, ok := m.responses[key]
   130  	if !ok {
   131  		return nil, status.Error(codes.Unknown, "no")
   132  	}
   133  	return res.response, res.err
   134  }
   135  
   136  func (m *mockVersionClient) GetManifests(ctx context.Context, in *api.GetManifestsRequest, opts ...grpc.CallOption) (*api.GetManifestsResponse, error) {
   137  	return nil, status.Error(codes.Unimplemented, "unimplemented")
   138  }
   139  
   140  type mockVersionEventProcessor struct {
   141  	events []KuberpultEvent
   142  }
   143  
   144  func (m *mockVersionEventProcessor) ProcessKuberpultEvent(ctx context.Context, ev KuberpultEvent) {
   145  	m.events = append(m.events, ev)
   146  }
   147  
   148  func TestVersionClientStream(t *testing.T) {
   149  	t.Parallel()
   150  	testOverview := &api.GetOverviewResponse{
   151  		Applications: map[string]*api.Application{
   152  			"foo": {
   153  				Releases: []*api.Release{
   154  					{
   155  						Version:        1,
   156  						SourceCommitId: "00001",
   157  					},
   158  				},
   159  				Team: "footeam",
   160  			},
   161  		},
   162  		EnvironmentGroups: []*api.EnvironmentGroup{
   163  			{
   164  
   165  				EnvironmentGroupName: "staging-group",
   166  				Environments: []*api.Environment{
   167  					{
   168  						Name: "staging",
   169  						Applications: map[string]*api.Environment_Application{
   170  							"foo": {
   171  								Name:    "foo",
   172  								Version: 1,
   173  								DeploymentMetaData: &api.Environment_Application_DeploymentMetaData{
   174  									DeployTime: "123456789",
   175  								},
   176  							},
   177  						},
   178  						Priority: api.Priority_UPSTREAM,
   179  					},
   180  				},
   181  			},
   182  		},
   183  		GitRevision: "1234",
   184  	}
   185  	testOverviewWithDifferentEnvgroup := &api.GetOverviewResponse{
   186  		Applications: map[string]*api.Application{
   187  			"foo": {
   188  				Releases: []*api.Release{
   189  					{
   190  						Version:        2,
   191  						SourceCommitId: "00002",
   192  					},
   193  				},
   194  			},
   195  		},
   196  		EnvironmentGroups: []*api.EnvironmentGroup{
   197  			{
   198  
   199  				EnvironmentGroupName: "not-staging-group",
   200  				Environments: []*api.Environment{
   201  					{
   202  						Name: "staging",
   203  						Applications: map[string]*api.Environment_Application{
   204  							"foo": {
   205  								Name:    "foo",
   206  								Version: 2,
   207  								DeploymentMetaData: &api.Environment_Application_DeploymentMetaData{
   208  									DeployTime: "123456789",
   209  								},
   210  							},
   211  						},
   212  						Priority: api.Priority_UPSTREAM,
   213  					},
   214  				},
   215  			},
   216  		},
   217  		GitRevision: "1234",
   218  	}
   219  	testOverviewWithProdEnvs := &api.GetOverviewResponse{
   220  		Applications: map[string]*api.Application{
   221  			"foo": {
   222  				Releases: []*api.Release{
   223  					{
   224  						Version:        2,
   225  						SourceCommitId: "00002",
   226  					},
   227  				},
   228  			},
   229  		},
   230  		EnvironmentGroups: []*api.EnvironmentGroup{
   231  			{
   232  
   233  				EnvironmentGroupName: "production",
   234  				Environments: []*api.Environment{
   235  					{
   236  						Name: "production",
   237  						Applications: map[string]*api.Environment_Application{
   238  							"foo": {
   239  								Name:    "foo",
   240  								Version: 2,
   241  								DeploymentMetaData: &api.Environment_Application_DeploymentMetaData{
   242  									DeployTime: "123456789",
   243  								},
   244  							},
   245  						},
   246  						Priority: api.Priority_PROD,
   247  					},
   248  				},
   249  			},
   250  		},
   251  		GitRevision: "1234",
   252  	}
   253  	emptyTestOverview := &api.GetOverviewResponse{
   254  		EnvironmentGroups: []*api.EnvironmentGroup{},
   255  		GitRevision:       "000",
   256  	}
   257  
   258  	tcs := []struct {
   259  		Name             string
   260  		Steps            []step
   261  		VersionResponses map[string]mockVersionResponse
   262  		ExpectedVersions []expectedVersion
   263  	}{
   264  		{
   265  			Name: "Retries connections and finishes",
   266  			Steps: []step{
   267  				{
   268  					ConnectErr: fmt.Errorf("no"),
   269  
   270  					ExpectReady: false,
   271  				},
   272  				{
   273  					RecvErr: fmt.Errorf("no"),
   274  
   275  					ExpectReady: false,
   276  				},
   277  				{
   278  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   279  					CancelContext: true,
   280  				},
   281  			},
   282  		},
   283  		{
   284  			Name: "Puts received overviews in the cache",
   285  			Steps: []step{
   286  				{
   287  					Overview: testOverview,
   288  
   289  					ExpectReady: true,
   290  					ExpectedEvents: []KuberpultEvent{
   291  						{
   292  							Environment:      "staging",
   293  							Application:      "foo",
   294  							EnvironmentGroup: "staging-group",
   295  							Team:             "footeam",
   296  							Version: &VersionInfo{
   297  								Version:        1,
   298  								SourceCommitId: "00001",
   299  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   300  							},
   301  						},
   302  					},
   303  				},
   304  				{
   305  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   306  					CancelContext: true,
   307  				},
   308  			},
   309  			ExpectedVersions: []expectedVersion{
   310  				{
   311  					Revision:        "1234",
   312  					Environment:     "staging",
   313  					Application:     "foo",
   314  					DeployedVersion: 1,
   315  					SourceCommitId:  "00001",
   316  					DeployTime:      time.Unix(123456789, 0).UTC(),
   317  				},
   318  			},
   319  		},
   320  		{
   321  			Name: "Can resolve versions from the versions client",
   322  			Steps: []step{
   323  				{
   324  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   325  					CancelContext: true,
   326  				},
   327  			},
   328  			VersionResponses: map[string]mockVersionResponse{
   329  				"staging/foo@1234": {
   330  					response: &api.GetVersionResponse{
   331  						Version:        1,
   332  						SourceCommitId: "00001",
   333  						DeployedAt:     timestamppb.New(time.Unix(123456789, 0).UTC()),
   334  					},
   335  				},
   336  			},
   337  			ExpectedVersions: []expectedVersion{
   338  				{
   339  					Revision:        "1234",
   340  					Environment:     "staging",
   341  					Application:     "foo",
   342  					DeployedVersion: 1,
   343  					SourceCommitId:  "00001",
   344  					DeployTime:      time.Unix(123456789, 0).UTC(),
   345  					VersionMetadata: metadata.MD{
   346  						"author-email": {"a3ViZXJwdWx0LXJvbGxvdXQtc2VydmljZUBsb2NhbA=="},
   347  						"author-name":  {"a3ViZXJwdWx0LXJvbGxvdXQtc2VydmljZQ=="},
   348  					},
   349  				},
   350  			},
   351  		},
   352  		{
   353  			Name: "Don't notify twice for the same version",
   354  			Steps: []step{
   355  				{
   356  					Overview: testOverview,
   357  
   358  					ExpectReady: true,
   359  					ExpectedEvents: []KuberpultEvent{
   360  						{
   361  							Environment:      "staging",
   362  							Application:      "foo",
   363  							EnvironmentGroup: "staging-group",
   364  							Team:             "footeam",
   365  							Version: &VersionInfo{
   366  								Version:        1,
   367  								SourceCommitId: "00001",
   368  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   369  							},
   370  						},
   371  					},
   372  				},
   373  				{
   374  					Overview: testOverview,
   375  
   376  					ExpectReady: true,
   377  				},
   378  				{
   379  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   380  					CancelContext: true,
   381  				},
   382  			},
   383  		},
   384  		{
   385  			Name: "Notify for apps that are deleted",
   386  			Steps: []step{
   387  				{
   388  					Overview: testOverview,
   389  
   390  					ExpectReady: true,
   391  					ExpectedEvents: []KuberpultEvent{
   392  						{
   393  							Environment:      "staging",
   394  							Application:      "foo",
   395  							EnvironmentGroup: "staging-group",
   396  							Team:             "footeam",
   397  							Version: &VersionInfo{
   398  								Version:        1,
   399  								SourceCommitId: "00001",
   400  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   401  							},
   402  						},
   403  					},
   404  				},
   405  				{
   406  					Overview: emptyTestOverview,
   407  
   408  					ExpectReady: true,
   409  					ExpectedEvents: []KuberpultEvent{
   410  						{
   411  							Environment:      "staging",
   412  							Application:      "foo",
   413  							EnvironmentGroup: "staging-group",
   414  							Team:             "footeam",
   415  							Version:          &VersionInfo{},
   416  						},
   417  					},
   418  				},
   419  				{
   420  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   421  					CancelContext: true,
   422  				},
   423  			},
   424  		},
   425  		{
   426  			Name: "Notify for apps that are deleted across reconnects",
   427  			Steps: []step{
   428  				{
   429  					Overview: testOverview,
   430  
   431  					ExpectReady: true,
   432  					ExpectedEvents: []KuberpultEvent{
   433  						{
   434  							Environment:      "staging",
   435  							Application:      "foo",
   436  							EnvironmentGroup: "staging-group",
   437  							Team:             "footeam",
   438  							Version: &VersionInfo{
   439  								Version:        1,
   440  								SourceCommitId: "00001",
   441  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   442  							},
   443  						},
   444  					},
   445  				},
   446  				{
   447  					RecvErr: fmt.Errorf("no"),
   448  
   449  					ExpectReady: false,
   450  				},
   451  				{
   452  					Overview: emptyTestOverview,
   453  
   454  					ExpectReady: true,
   455  					ExpectedEvents: []KuberpultEvent{
   456  						{
   457  							Environment:      "staging",
   458  							Application:      "foo",
   459  							EnvironmentGroup: "staging-group",
   460  							Team:             "footeam",
   461  							Version:          &VersionInfo{},
   462  						},
   463  					},
   464  				},
   465  				{
   466  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   467  					CancelContext: true,
   468  				},
   469  			},
   470  		},
   471  		{
   472  			Name: "Updates environment groups",
   473  			Steps: []step{
   474  				{
   475  					Overview: testOverview,
   476  
   477  					ExpectReady: true,
   478  					ExpectedEvents: []KuberpultEvent{
   479  						{
   480  							Environment:      "staging",
   481  							Application:      "foo",
   482  							EnvironmentGroup: "staging-group",
   483  							Team:             "footeam",
   484  							Version: &VersionInfo{
   485  								Version:        1,
   486  								SourceCommitId: "00001",
   487  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   488  							},
   489  						},
   490  					},
   491  				},
   492  				{
   493  					Overview: testOverviewWithDifferentEnvgroup,
   494  
   495  					ExpectReady: true,
   496  					ExpectedEvents: []KuberpultEvent{
   497  						{
   498  							Environment:      "staging",
   499  							Application:      "foo",
   500  							EnvironmentGroup: "not-staging-group",
   501  							Team:             "",
   502  							Version: &VersionInfo{
   503  								Version:        2,
   504  								SourceCommitId: "00002",
   505  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   506  							},
   507  						},
   508  					},
   509  				},
   510  				{
   511  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   512  					CancelContext: true,
   513  				},
   514  			},
   515  		},
   516  		{
   517  			Name: "Reports production environments",
   518  			Steps: []step{
   519  				{
   520  					Overview: testOverviewWithProdEnvs,
   521  
   522  					ExpectReady: true,
   523  					ExpectedEvents: []KuberpultEvent{
   524  						{
   525  							Environment:      "production",
   526  							Application:      "foo",
   527  							EnvironmentGroup: "production",
   528  							IsProduction:     true,
   529  							Version: &VersionInfo{
   530  								Version:        2,
   531  								SourceCommitId: "00002",
   532  								DeployedAt:     time.Unix(123456789, 0).UTC(),
   533  							},
   534  						},
   535  					},
   536  				},
   537  				{
   538  					RecvErr:       status.Error(codes.Canceled, "context cancelled"),
   539  					CancelContext: true,
   540  				},
   541  			},
   542  		},
   543  	}
   544  	for _, tc := range tcs {
   545  		tc := tc
   546  		t.Run(tc.Name, func(t *testing.T) {
   547  			ctx, cancel := context.WithCancel(context.Background())
   548  			vp := &mockVersionEventProcessor{}
   549  			startSteps := make(chan struct{})
   550  			steps := make(chan step)
   551  			moc := &mockOverviewClient{StartStep: startSteps, Steps: steps}
   552  			if tc.VersionResponses == nil {
   553  				tc.VersionResponses = map[string]mockVersionResponse{}
   554  			}
   555  			mvc := &mockVersionClient{responses: tc.VersionResponses}
   556  			vc := New(moc, mvc, nil, false, []string{})
   557  			hs := &setup.HealthServer{}
   558  			hs.BackOffFactory = func() backoff.BackOff {
   559  				return backoff.NewConstantBackOff(time.Millisecond)
   560  			}
   561  			errCh := make(chan error)
   562  			go func() {
   563  				errCh <- vc.ConsumeEvents(ctx, vp, hs.Reporter("versions"))
   564  			}()
   565  			for i, s := range tc.Steps {
   566  				<-startSteps
   567  				if i > 0 {
   568  					assertStep(t, i-1, tc.Steps[i-1], vp, hs)
   569  				}
   570  				if s.CancelContext {
   571  					cancel()
   572  				}
   573  				select {
   574  				case steps <- s:
   575  				case err := <-errCh:
   576  					t.Fatalf("expected no error but received %q", err)
   577  				case <-time.After(10 * time.Second):
   578  					t.Fatal("test got stuck after 10 seconds")
   579  				}
   580  			}
   581  			cancel()
   582  			err := <-errCh
   583  			if err != nil {
   584  				t.Errorf("expected no error, but received %q", err)
   585  			}
   586  			if len(steps) != 0 {
   587  				t.Errorf("expected all events to be consumed, but got %d left", len(steps))
   588  			}
   589  			assertExpectedVersions(t, tc.ExpectedVersions, vc, moc, mvc)
   590  
   591  		})
   592  	}
   593  }
   594  
   595  func assertStep(t *testing.T, i int, s step, vp *mockVersionEventProcessor, hs *setup.HealthServer) {
   596  	if hs.IsReady("versions") != s.ExpectReady {
   597  		t.Errorf("wrong readyness in step %d, expected %t but got %t", i, s.ExpectReady, hs.IsReady("versions"))
   598  	}
   599  	if !cmp.Equal(s.ExpectedEvents, vp.events) {
   600  		t.Errorf("version events differ: %s", cmp.Diff(s.ExpectedEvents, vp.events))
   601  	}
   602  	vp.events = nil
   603  }
   604  
   605  func assertExpectedVersions(t *testing.T, expectedVersions []expectedVersion, vc VersionClient, mc *mockOverviewClient, mvc *mockVersionClient) {
   606  	for _, ev := range expectedVersions {
   607  		version, err := vc.GetVersion(context.Background(), ev.Revision, ev.Environment, ev.Application)
   608  		if err != nil {
   609  			t.Errorf("expected no error for %s/%s@%s, but got %q", ev.Environment, ev.Application, ev.Revision, err)
   610  			continue
   611  		}
   612  		if version.Version != ev.DeployedVersion {
   613  			t.Errorf("expected version %d to be deployed for %s/%s@%s but got %d", ev.DeployedVersion, ev.Environment, ev.Application, ev.Revision, version.Version)
   614  		}
   615  		if version.DeployedAt != ev.DeployTime {
   616  			t.Errorf("expected deploy time to be %q for %s/%s@%s but got %q", ev.DeployTime, ev.Environment, ev.Application, ev.Revision, version.DeployedAt)
   617  		}
   618  		if version.SourceCommitId != ev.SourceCommitId {
   619  			t.Errorf("expected source commit id to be %q for %s/%s@%s but got %q", ev.SourceCommitId, ev.Environment, ev.Application, ev.Revision, version.SourceCommitId)
   620  		}
   621  		if !cmp.Equal(mc.LastMetadata, ev.OverviewMetadata) {
   622  			t.Errorf("mismachted version metadata %s", cmp.Diff(mc.LastMetadata, ev.OverviewMetadata))
   623  		}
   624  		if !cmp.Equal(mvc.LastMetadata, ev.VersionMetadata) {
   625  			t.Errorf("mismachted version metadata %s", cmp.Diff(mvc.LastMetadata, ev.VersionMetadata))
   626  		}
   627  
   628  	}
   629  }