github.com/aristanetworks/goarista@v0.0.0-20240514173732-cca2755bbd44/gnmireverse/client/client_test.go (about) 1 // Copyright (c) 2020 Arista Networks, Inc. 2 // Use of this source code is governed by the Apache License 2.0 3 // that can be found in the COPYING file. 4 5 package client 6 7 import ( 8 "context" 9 "fmt" 10 "net" 11 "testing" 12 "time" 13 14 "github.com/aristanetworks/glog" 15 "github.com/aristanetworks/goarista/gnmireverse" 16 "github.com/openconfig/gnmi/proto/gnmi" 17 "golang.org/x/sync/errgroup" 18 "google.golang.org/grpc" 19 "google.golang.org/grpc/codes" 20 "google.golang.org/grpc/status" 21 "google.golang.org/protobuf/proto" 22 ) 23 24 func TestSampleList(t *testing.T) { 25 for name, tc := range map[string]struct { 26 arg string 27 28 error bool 29 path *gnmi.Path 30 interval time.Duration 31 }{ 32 "working": { 33 arg: "/foos/foo[name=bar]/baz@30s", 34 35 path: &gnmi.Path{Elem: []*gnmi.PathElem{ 36 &gnmi.PathElem{Name: "foos"}, 37 &gnmi.PathElem{Name: "foo", 38 Key: map[string]string{"name": "bar"}}, 39 &gnmi.PathElem{Name: "baz"}, 40 }}, 41 interval: 30 * time.Second, 42 }, 43 "no_interval": { 44 arg: "/foos/foo[name=bar]/baz", 45 error: true, 46 }, 47 "empty_interval": { 48 arg: "/foos/foo[name=bar]/baz@", 49 error: true, 50 }, 51 "invalid_path": { 52 arg: "/foos/foo[name=bar]]/baz@30s", 53 error: true, 54 }, 55 } { 56 t.Run(name, func(t *testing.T) { 57 var l sampleList 58 err := l.Set(tc.arg) 59 if err != nil { 60 if !tc.error { 61 t.Fatalf("unexpected error: %s", err) 62 } 63 return 64 } else if tc.error { 65 t.Fatal("expected error and didn't get one") 66 } 67 68 sub := l.subs[0] 69 sub.p.Element = nil // Ignore the backward compatible path 70 if !proto.Equal(tc.path, sub.p) { 71 t.Errorf("Paths don't match. Expected: %s Got: %s", 72 tc.path, sub.p) 73 } 74 if tc.interval != sub.interval { 75 t.Errorf("Intervals don't match. Expected %s Got: %s", 76 tc.interval, sub.interval) 77 } 78 str := l.String() 79 if tc.arg != str { 80 t.Errorf("Unexpected String() result: Expected: %q Got: %q", tc.arg, str) 81 } 82 }) 83 } 84 } 85 86 func TestSubscriptionList(t *testing.T) { 87 for name, tc := range map[string]struct { 88 arg string 89 90 error bool 91 path *gnmi.Path 92 interval time.Duration 93 }{ 94 "working": { 95 arg: "/foos/foo[name=bar]/baz@30s", 96 97 path: &gnmi.Path{Elem: []*gnmi.PathElem{ 98 &gnmi.PathElem{Name: "foos"}, 99 &gnmi.PathElem{Name: "foo", 100 Key: map[string]string{"name": "bar"}}, 101 &gnmi.PathElem{Name: "baz"}, 102 }}, 103 interval: 30 * time.Second, 104 }, 105 "no_interval": { 106 arg: "/foos/foo[name=bar]/baz", 107 path: &gnmi.Path{Elem: []*gnmi.PathElem{ 108 &gnmi.PathElem{Name: "foos"}, 109 &gnmi.PathElem{Name: "foo", 110 Key: map[string]string{"name": "bar"}}, 111 &gnmi.PathElem{Name: "baz"}, 112 }}, 113 }, 114 "empty_interval": { 115 arg: "/foos/foo[name=bar]/baz@", 116 error: true, 117 }, 118 "invalid_path": { 119 arg: "/foos/foo[name=bar]]/baz@30s", 120 error: true, 121 }, 122 } { 123 t.Run(name, func(t *testing.T) { 124 var l subscriptionList 125 err := l.Set(tc.arg) 126 if err != nil { 127 if !tc.error { 128 t.Fatalf("unexpected error: %s", err) 129 } 130 return 131 } else if tc.error { 132 t.Fatal("expected error and didn't get one") 133 } 134 135 sub := l.subs[0] 136 sub.p.Element = nil // Ignore the backward compatible path 137 if !proto.Equal(tc.path, sub.p) { 138 t.Errorf("Paths don't match. Expected: %s Got: %s", 139 tc.path, sub.p) 140 } 141 if tc.interval != sub.interval { 142 t.Errorf("Intervals don't match. Expected %s Got: %s", 143 tc.interval, sub.interval) 144 } 145 str := l.String() 146 if tc.arg != str { 147 t.Errorf("Unexpected String() result: Expected: %q Got: %q", tc.arg, str) 148 } 149 }) 150 } 151 } 152 153 func TestParseCredentialsFile(t *testing.T) { 154 for _, tc := range []struct { 155 name string 156 config *config 157 credentialsFile []byte 158 username string 159 password string 160 err bool 161 }{{ 162 name: "empty config, username/password in credentials file", 163 config: &config{}, 164 credentialsFile: []byte(` 165 username: admin 166 password: pass123 167 `), 168 username: "admin", 169 password: "pass123", 170 }, { 171 name: "empty config, only username in credentials file", 172 config: &config{}, 173 credentialsFile: []byte(` 174 username: admin 175 `), 176 username: "admin", 177 }, { 178 name: "username in config, only password in credentials file", 179 config: &config{ 180 username: "admin", 181 }, 182 credentialsFile: []byte(` 183 password: pass123 184 `), 185 username: "admin", 186 password: "pass123", 187 }, { 188 name: "username/password in config takes precedence over credentials file", 189 config: &config{ 190 username: "admin", 191 password: "pass123", 192 }, 193 credentialsFile: []byte(` 194 username: bob 195 password: secret123 196 `), 197 username: "admin", 198 password: "pass123", 199 }, { 200 name: "username/password in config, empty credentials file", 201 config: &config{ 202 username: "admin", 203 password: "pass123", 204 }, 205 username: "admin", 206 password: "pass123", 207 }, { 208 name: "username/password in credentials file with newlines, quotes and spaces", 209 config: &config{}, 210 credentialsFile: []byte(` 211 username: "ad min" 212 213 password: pass 123 214 `), 215 username: "ad min", 216 password: "pass 123", 217 }, { 218 name: "unknown field in credentials file", 219 config: &config{}, 220 credentialsFile: []byte(` 221 username: admin 222 password: pass123 223 unknown: unknown 224 `), 225 err: true, 226 }} { 227 t.Run(tc.name, func(t *testing.T) { 228 if err := tc.config.parseCredentialsFile(tc.credentialsFile); tc.err != (err != nil) { 229 t.Errorf("want error %t, got error: %s", tc.err, err) 230 } 231 if tc.config.username != tc.username { 232 t.Errorf("want username: %q, got: %q", tc.username, tc.config.username) 233 } 234 if tc.config.password != tc.password { 235 t.Errorf("want password: %q, got: %q", tc.password, tc.config.password) 236 } 237 }) 238 } 239 } 240 241 func TestStreamGetResponses(t *testing.T) { 242 // Set the Get paths list. 243 var cfgGetList getList 244 cfgGetList.Set("/foo/bar") 245 246 cfg := &config{ 247 targetVal: "baz", 248 targetAddr: getTestAddress(t), 249 collectorAddr: getTestAddress(t), 250 getPaths: cfgGetList, 251 getSampleInterval: time.Second, 252 } 253 254 collectorErrChan := make(chan error, 1) 255 gnmiServer := &gnmiServer{} 256 gnmireverseServer := &gnmireverseServer{ 257 errChan: collectorErrChan, 258 } 259 runStreamGetResponsesTest(t, cfg, collectorErrChan, 260 gnmiServer, gnmireverseServer, streamGetResponses) 261 } 262 263 // getTestAddress gets a localhost address with a random unused port. 264 func getTestAddress(t *testing.T) string { 265 listener, err := net.Listen("tcp", "127.0.0.1:0") 266 if err != nil { 267 t.Fatalf("failed to find an available port: %s", err) 268 } 269 defer listener.Close() 270 return listener.Addr().String() 271 } 272 273 // runStreamGetResponsesTest runs the gNMIReverse Get client with the mock gNMI server and 274 // mock gNMIReverse server and checks if the collectorErrChan receives an error. 275 func runStreamGetResponsesTest(t *testing.T, cfg *config, collectorErrChan chan error, 276 gnmiServer gnmi.GNMIServer, gnmireverseServer gnmireverse.GNMIReverseServer, 277 streamResponsesFunc func(*config, *grpc.ClientConn, *grpc.ClientConn) func( 278 context.Context, *errgroup.Group)) { 279 // Start the mock gNMI target server. 280 targetGRPCServer := grpc.NewServer() 281 gnmi.RegisterGNMIServer(targetGRPCServer, gnmiServer) 282 targetListener, err := net.Listen("tcp", cfg.targetAddr) 283 if err != nil { 284 t.Fatal(err) 285 } 286 glog.V(1).Infof("gNMI target server listening on %s", cfg.targetAddr) 287 go func() { 288 if err := targetGRPCServer.Serve(targetListener); err != nil { 289 t.Error(err) 290 } 291 }() 292 293 // Start the mock gNMIReverse collector server. 294 collectorGRPCServer := grpc.NewServer() 295 gnmireverse.RegisterGNMIReverseServer(collectorGRPCServer, gnmireverseServer) 296 collectorListener, err := net.Listen("tcp", cfg.collectorAddr) 297 if err != nil { 298 t.Fatal(err) 299 } 300 glog.V(1).Infof("gNMIReverse collector server listening on %s", cfg.collectorAddr) 301 go func() { 302 if err := collectorGRPCServer.Serve(collectorListener); err != nil { 303 t.Error(err) 304 } 305 }() 306 307 // Start the gNMIReverse client to stream GetResponses from target to collector. 308 destConn, err := dialCollector(cfg) 309 if err != nil { 310 glog.Fatalf("error dialing destination %q: %s", cfg.collectorAddr, err) 311 } 312 targetConn, err := dialTarget(cfg) 313 if err != nil { 314 glog.Fatalf("error dialing target %q: %s", cfg.targetAddr, err) 315 } 316 glog.V(1).Infof("gNMIReverse client publish Get response from %s to %s", 317 targetConn.Target(), destConn.Target()) 318 go func() { 319 streamResponses(streamResponsesFunc(cfg, destConn, targetConn)) 320 }() 321 322 // Check that the gNMIReverse collector server receives the expected Get response. 323 if err := <-collectorErrChan; err != nil { 324 t.Error(err) 325 } 326 } 327 328 var testNotification = &gnmi.Notification{ 329 Prefix: &gnmi.Path{ 330 Target: "baz", 331 }, 332 Update: []*gnmi.Update{{ 333 Path: &gnmi.Path{ 334 Elem: []*gnmi.PathElem{ 335 {Name: "foo"}, 336 {Name: "bar"}, 337 }, 338 }, 339 Val: &gnmi.TypedValue{ 340 Value: &gnmi.TypedValue_IntVal{ 341 IntVal: 1, 342 }, 343 }, 344 }}, 345 } 346 347 var testGetResponse = &gnmi.GetResponse{ 348 Notification: []*gnmi.Notification{testNotification}, 349 } 350 351 // Mock gNMIReverse server checks if the published Get response matches the testGetResponse. 352 type gnmireverseServer struct { 353 errChan chan error 354 gnmireverse.UnimplementedGNMIReverseServer 355 } 356 357 func (s *gnmireverseServer) PublishGet(stream gnmireverse.GNMIReverse_PublishGetServer) error { 358 for { 359 res, err := stream.Recv() 360 if err != nil { 361 return err 362 } 363 // Overwrite the timestamp so notification can be compared. 364 res.Notification[0].Timestamp = 0 365 if !proto.Equal(testGetResponse, res) { 366 s.errChan <- fmt.Errorf( 367 "Get response not equal: want %v, got %v", testGetResponse, res) 368 } else { 369 s.errChan <- nil 370 } 371 } 372 } 373 374 // Mock gNMI server returns testGetResponse for Get. 375 type gnmiServer struct { 376 gnmi.UnimplementedGNMIServer 377 } 378 379 func (*gnmiServer) Get(ctx context.Context, req *gnmi.GetRequest) (*gnmi.GetResponse, error) { 380 return testGetResponse, nil 381 } 382 383 func TestCombineGetResponses(t *testing.T) { 384 for name, tc := range map[string]struct { 385 getResponses []*gnmi.GetResponse 386 combinedGetResponse *gnmi.GetResponse 387 }{ 388 "0_notifs_0_notifs_total_0_notifs": { 389 getResponses: []*gnmi.GetResponse{ 390 {}, 391 {}, 392 }, 393 combinedGetResponse: &gnmi.GetResponse{}, 394 }, 395 "1_notif_0_notif_total_1_notif": { 396 getResponses: []*gnmi.GetResponse{ 397 testGetResponse, 398 {}, 399 }, 400 combinedGetResponse: testGetResponse, 401 }, 402 "0_notif_1_notif_total_1_notif": { 403 getResponses: []*gnmi.GetResponse{ 404 {}, 405 testEOSNativeGetResponse, 406 }, 407 combinedGetResponse: testEOSNativeGetResponse, 408 }, 409 "1_notif_1_notif_total_2_notifs": { 410 getResponses: []*gnmi.GetResponse{ 411 testGetResponse, 412 testEOSNativeGetResponse, 413 }, 414 combinedGetResponse: testCombinedGetResponse, 415 }, 416 } { 417 t.Run(name, func(t *testing.T) { 418 timestamp := int64(123) 419 target := "baz" 420 combinedGetResponse := combineGetResponses(timestamp, target, tc.getResponses...) 421 if !proto.Equal(tc.combinedGetResponse, combinedGetResponse) { 422 t.Errorf("combined Get responses do not match, expected: %v, got: %v", 423 tc.combinedGetResponse, combinedGetResponse) 424 } 425 }) 426 } 427 } 428 429 func TestStreamMixedOriginGetResponses(t *testing.T) { 430 // Set the Get paths list with one OpenConfig and one EOS native path. 431 var cfgGetList getList 432 cfgGetList.Set("openconfig:/foo/bar") 433 cfgGetList.Set("eos_native:/a/b") 434 435 cfg := &config{ 436 targetVal: "baz", 437 targetAddr: getTestAddress(t), 438 collectorAddr: getTestAddress(t), 439 getPaths: cfgGetList, 440 getSampleInterval: time.Second, 441 } 442 443 collectorErrChan := make(chan error, 1) 444 gnmiServer := &mixedOriginGNMIServer{} 445 gnmireverseServer := &mixedOriginGNMIReverseServer{ 446 errChan: collectorErrChan, 447 } 448 runStreamGetResponsesTest(t, cfg, collectorErrChan, 449 gnmiServer, gnmireverseServer, streamGetResponses) 450 } 451 452 var testEOSNativeNotification = &gnmi.Notification{ 453 Timestamp: 123, 454 Prefix: &gnmi.Path{ 455 Target: "baz", 456 Origin: "eos_native", 457 }, 458 Update: []*gnmi.Update{{ 459 Path: &gnmi.Path{ 460 Elem: []*gnmi.PathElem{ 461 {Name: "a"}, 462 {Name: "b"}, 463 }, 464 }, 465 Val: &gnmi.TypedValue{ 466 Value: &gnmi.TypedValue_StringVal{ 467 StringVal: "c", 468 }, 469 }, 470 }}, 471 } 472 473 var testEOSNativeGetResponse = &gnmi.GetResponse{ 474 Notification: []*gnmi.Notification{testEOSNativeNotification}, 475 } 476 477 var testCombinedGetResponse = &gnmi.GetResponse{ 478 Notification: []*gnmi.Notification{testNotification, testEOSNativeNotification}, 479 } 480 481 // Mock gNMIReverse server checks if the published Get response matches the 482 // testCombinedGetResponse. 483 type mixedOriginGNMIReverseServer struct { 484 errChan chan error 485 gnmireverse.UnimplementedGNMIReverseServer 486 } 487 488 func (s *mixedOriginGNMIReverseServer) PublishGet( 489 stream gnmireverse.GNMIReverse_PublishGetServer) error { 490 for { 491 res, err := stream.Recv() 492 if err != nil { 493 return err 494 } 495 // Overwrite the OpenConfig and EOS native notification timestamps so notification 496 // can be compared. 497 res.Notification[0].Timestamp = 123 498 res.Notification[1].Timestamp = 123 499 if !proto.Equal(testCombinedGetResponse, res) { 500 s.errChan <- fmt.Errorf( 501 "Get response not equal: want %v, got %v", testGetResponse, res) 502 } else { 503 s.errChan <- nil 504 } 505 } 506 } 507 508 // Mock gNMI server returns for Get the testGetResponse for OpenConfig origins and 509 // testEOSNativeGetResponse for EOS native origins. 510 type mixedOriginGNMIServer struct { 511 gnmi.UnimplementedGNMIServer 512 } 513 514 func (*mixedOriginGNMIServer) Get( 515 ctx context.Context, req *gnmi.GetRequest) (*gnmi.GetResponse, error) { 516 if req.GetPath()[0].GetOrigin() == "eos_native" { 517 return testEOSNativeGetResponse, nil 518 } 519 return testGetResponse, nil 520 } 521 522 func TestStreamGetResponsesModeSubscribe(t *testing.T) { 523 // Set the Get paths list with one OpenConfig and one EOS native path. 524 var cfgGetList getList 525 cfgGetList.Set("/foo/bar") 526 cfgGetList.Set("eos_native:/a/b") 527 528 cfg := &config{ 529 targetVal: "baz", 530 targetAddr: getTestAddress(t), 531 collectorAddr: getTestAddress(t), 532 getPaths: cfgGetList, 533 getSampleInterval: time.Second, 534 } 535 536 collectorErrChan := make(chan error, 1) 537 gnmiServer := &gnmiServerGetModeSubscribe{} 538 gnmireverseServer := &gnmireverseServerGetModeSubscribe{ 539 errChan: collectorErrChan, 540 } 541 runStreamGetResponsesTest(t, cfg, collectorErrChan, 542 gnmiServer, gnmireverseServer, streamGetResponsesModeSubscribe) 543 } 544 545 func TestStreamGetResponsesModeSubscribeOnceNotSupported(t *testing.T) { 546 // Set the Get paths list with one OpenConfig and one EOS native path. 547 var cfgGetList getList 548 cfgGetList.Set("/foo/bar") 549 cfgGetList.Set("eos_native:/a/b") 550 551 cfg := &config{ 552 targetVal: "baz", 553 targetAddr: getTestAddress(t), 554 collectorAddr: getTestAddress(t), 555 getPaths: cfgGetList, 556 getSampleInterval: time.Second, 557 } 558 559 collectorErrChan := make(chan error, 1) 560 gnmiServer := &gnmiServerGetModeSubscribe{ 561 subscribeOnceNotSupported: true, 562 } 563 gnmireverseServer := &gnmireverseServerGetModeSubscribe{ 564 errChan: collectorErrChan, 565 } 566 runStreamGetResponsesTest(t, cfg, collectorErrChan, 567 gnmiServer, gnmireverseServer, streamGetResponsesModeSubscribe) 568 } 569 570 var testSubscribeResponse = &gnmi.SubscribeResponse{ 571 Response: &gnmi.SubscribeResponse_Update{ 572 Update: testNotification, 573 }, 574 } 575 576 var testEOSNativeSubscribeResponse = &gnmi.SubscribeResponse{ 577 Response: &gnmi.SubscribeResponse_Update{ 578 Update: testEOSNativeNotification, 579 }, 580 } 581 582 // Mock gNMIReverse server checks if the published Get response matches the 583 // testCombinedGetResponse. 584 type gnmireverseServerGetModeSubscribe struct { 585 errChan chan error 586 gnmireverse.UnimplementedGNMIReverseServer 587 } 588 589 func (s *gnmireverseServerGetModeSubscribe) PublishGet( 590 stream gnmireverse.GNMIReverse_PublishGetServer) error { 591 for { 592 res, err := stream.Recv() 593 if err != nil { 594 return err 595 } 596 if !proto.Equal(testCombinedGetResponse, res) { 597 s.errChan <- fmt.Errorf( 598 "Get response not equal: want %v, got %v", testCombinedGetResponse, res) 599 } else { 600 s.errChan <- nil 601 } 602 } 603 } 604 605 // gNMI server which mocks the Subscribe behaviour and sends expected 606 // responses for OpenConfig and EOS native path subscriptions. 607 type gnmiServerGetModeSubscribe struct { 608 subscribeOnceNotSupported bool 609 gnmi.UnimplementedGNMIServer 610 } 611 612 func (s *gnmiServerGetModeSubscribe) Subscribe(stream gnmi.GNMI_SubscribeServer) error { 613 req, err := stream.Recv() 614 if err != nil { 615 return err 616 } 617 origin := req.GetSubscribe().GetSubscription()[0].GetPath().GetOrigin() 618 switch origin { 619 case "": 620 return openconfigSubscribePoll(stream, req) 621 case "eos_native": 622 mode := req.GetSubscribe().GetMode() 623 switch mode { 624 case gnmi.SubscriptionList_ONCE: 625 if s.subscribeOnceNotSupported { 626 return status.Errorf(codes.Unimplemented, "Subscribe ONCE mode not supported") 627 } 628 return eosNativeSubscribeOnce(stream, req) 629 case gnmi.SubscriptionList_STREAM: 630 return eosNativeSubscribeStream(stream) 631 default: 632 return fmt.Errorf("unexpected Subscribe mode: %s", mode.String()) 633 } 634 default: 635 return fmt.Errorf("unexpected Subscribe origin: %s", origin) 636 } 637 } 638 639 var syncResponse = &gnmi.SubscribeResponse{ 640 Response: &gnmi.SubscribeResponse_SyncResponse{ 641 SyncResponse: true, 642 }, 643 } 644 645 // openconfigSubscribePoll mocks the server Subscribe POLL behavior. 646 func openconfigSubscribePoll(stream gnmi.GNMI_SubscribeServer, req *gnmi.SubscribeRequest) error { 647 if !(req.GetSubscribe().GetMode() == gnmi.SubscriptionList_POLL && 648 req.GetSubscribe().GetUpdatesOnly()) { 649 return fmt.Errorf("expect SubscribeRequest mode=POLL and updates_only=true,"+ 650 " got mode=%s and updates_only=%t", req.GetSubscribe().GetMode(), 651 req.GetSubscribe().GetUpdatesOnly()) 652 } 653 // Send initial sync response. 654 if err := stream.Send(syncResponse); err != nil { 655 return err 656 } 657 658 for { 659 // Await for poll trigger request. 660 req, err := stream.Recv() 661 if err != nil { 662 return err 663 } 664 if req.GetPoll() == nil { 665 return fmt.Errorf("expect Subscribe POLL trigger request, got %s", req) 666 } 667 // Send one notification. 668 if err := stream.Send(testSubscribeResponse); err != nil { 669 return err 670 } 671 // Mark the end of the poll updates with a sync response. 672 if err := stream.Send(syncResponse); err != nil { 673 return err 674 } 675 } 676 } 677 678 // eosNativeSubscribeOnce mocks the server Subscribe ONCE behavior. 679 func eosNativeSubscribeOnce(stream gnmi.GNMI_SubscribeServer, req *gnmi.SubscribeRequest) error { 680 if !req.GetSubscribe().GetUpdatesOnly() { 681 // Send one notification. 682 if err := stream.Send(testEOSNativeSubscribeResponse); err != nil { 683 return err 684 } 685 } 686 // Mark the end of updates with a sync response. 687 return stream.Send(syncResponse) 688 } 689 690 // eosNativeSubscribeStream mocks the server Subscribe STREAM behavior. 691 func eosNativeSubscribeStream(stream gnmi.GNMI_SubscribeServer) error { 692 // Send one notification. 693 if err := stream.Send(testEOSNativeSubscribeResponse); err != nil { 694 return err 695 } 696 // Mark the end of updates with a sync response. 697 if err := stream.Send(syncResponse); err != nil { 698 return err 699 } 700 // Wait for the gNMIReverse client to close the stream. 701 <-stream.Context().Done() 702 return nil 703 }