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 }