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 }