github.com/sl1pm4t/consul@v1.4.5-0.20190325224627-74c31c540f9c/agent/xds/server_test.go (about) 1 package xds 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "log" 9 "os" 10 "strings" 11 "sync" 12 "sync/atomic" 13 "testing" 14 "text/template" 15 "time" 16 17 envoy "github.com/envoyproxy/go-control-plane/envoy/api/v2" 18 "github.com/gogo/protobuf/jsonpb" 19 "github.com/stretchr/testify/require" 20 "google.golang.org/grpc/codes" 21 "google.golang.org/grpc/metadata" 22 "google.golang.org/grpc/status" 23 24 "github.com/hashicorp/consul/acl" 25 "github.com/hashicorp/consul/agent/cache" 26 "github.com/hashicorp/consul/agent/proxycfg" 27 "github.com/hashicorp/consul/agent/structs" 28 ) 29 30 // testManager is a mock of proxycfg.Manager that's simpler to control for 31 // testing. It also implements ConnectAuthz to allow control over authorization. 32 type testManager struct { 33 sync.Mutex 34 chans map[string]chan *proxycfg.ConfigSnapshot 35 cancels chan string 36 authz map[string]connectAuthzResult 37 } 38 39 type connectAuthzResult struct { 40 authz bool 41 reason string 42 m *cache.ResultMeta 43 err error 44 } 45 46 func newTestManager(t *testing.T) *testManager { 47 return &testManager{ 48 chans: map[string]chan *proxycfg.ConfigSnapshot{}, 49 cancels: make(chan string, 10), 50 authz: make(map[string]connectAuthzResult), 51 } 52 } 53 54 // RegisterProxy simulates a proxy registration 55 func (m *testManager) RegisterProxy(t *testing.T, proxyID string) { 56 m.Lock() 57 defer m.Unlock() 58 m.chans[proxyID] = make(chan *proxycfg.ConfigSnapshot, 1) 59 } 60 61 // Deliver simulates a proxy registration 62 func (m *testManager) DeliverConfig(t *testing.T, proxyID string, cfg *proxycfg.ConfigSnapshot) { 63 t.Helper() 64 m.Lock() 65 defer m.Unlock() 66 select { 67 case m.chans[proxyID] <- cfg: 68 case <-time.After(10 * time.Millisecond): 69 t.Fatalf("took too long to deliver config") 70 } 71 } 72 73 // Watch implements ConfigManager 74 func (m *testManager) Watch(proxyID string) (<-chan *proxycfg.ConfigSnapshot, proxycfg.CancelFunc) { 75 m.Lock() 76 defer m.Unlock() 77 // ch might be nil but then it will just block forever 78 return m.chans[proxyID], func() { 79 m.cancels <- proxyID 80 } 81 } 82 83 // AssertWatchCancelled checks that the most recent call to a Watch cancel func 84 // was from the specified proxyID and that one is made in a short time. This 85 // probably won't work if you are running multiple Watches in parallel on 86 // multiple proxyIDS due to timing/ordering issues but I don't think we need to 87 // do that. 88 func (m *testManager) AssertWatchCancelled(t *testing.T, proxyID string) { 89 t.Helper() 90 select { 91 case got := <-m.cancels: 92 require.Equal(t, proxyID, got) 93 case <-time.After(50 * time.Millisecond): 94 t.Fatalf("timed out waiting for Watch cancel for %s", proxyID) 95 } 96 } 97 98 // ConnectAuthorize implements ConnectAuthz 99 func (m *testManager) ConnectAuthorize(token string, req *structs.ConnectAuthorizeRequest) (authz bool, reason string, meta *cache.ResultMeta, err error) { 100 m.Lock() 101 defer m.Unlock() 102 if res, ok := m.authz[token]; ok { 103 return res.authz, res.reason, res.m, res.err 104 } 105 // Default allow but with reason that won't match by accident in a test case 106 return true, "OK: allowed by default test implementation", nil, nil 107 } 108 109 func TestServer_StreamAggregatedResources_BasicProtocol(t *testing.T) { 110 logger := log.New(os.Stderr, "", log.LstdFlags) 111 mgr := newTestManager(t) 112 aclResolve := func(id string) (acl.Authorizer, error) { 113 // Allow all 114 return acl.RootAuthorizer("manage"), nil 115 } 116 envoy := NewTestEnvoy(t, "web-sidecar-proxy", "") 117 defer envoy.Close() 118 119 s := Server{ 120 Logger: logger, 121 CfgMgr: mgr, 122 Authz: mgr, 123 ResolveToken: aclResolve, 124 } 125 s.Initialize() 126 127 go func() { 128 err := s.StreamAggregatedResources(envoy.stream) 129 require.NoError(t, err) 130 }() 131 132 // Register the proxy to create state needed to Watch() on 133 mgr.RegisterProxy(t, "web-sidecar-proxy") 134 135 // Send initial cluster discover 136 envoy.SendReq(t, ClusterType, 0, 0) 137 138 // Check no response sent yet 139 assertChanBlocked(t, envoy.stream.sendCh) 140 141 // Deliver a new snapshot 142 snap := proxycfg.TestConfigSnapshot(t) 143 mgr.DeliverConfig(t, "web-sidecar-proxy", snap) 144 145 assertResponseSent(t, envoy.stream.sendCh, expectClustersJSON(t, snap, "", 1, 1)) 146 147 // Envoy then tries to discover endpoints for those clusters. Technically it 148 // includes the cluster names in the ResourceNames field but we ignore that 149 // completely for now so not bothering to simulate that. 150 envoy.SendReq(t, EndpointType, 0, 0) 151 152 // It also (in parallel) issues the next cluster request (which acts as an ACK 153 // of the version we sent) 154 envoy.SendReq(t, ClusterType, 1, 1) 155 156 // We should get a response immediately since the config is already present in 157 // the server for endpoints. Note that this should not be racy if the server 158 // is behaving well since the Cluster send above should be blocked until we 159 // deliver a new config version. 160 assertResponseSent(t, envoy.stream.sendCh, expectEndpointsJSON(t, snap, "", 1, 2)) 161 162 // And no other response yet 163 assertChanBlocked(t, envoy.stream.sendCh) 164 165 // Envoy now sends listener request along with next endpoint one 166 envoy.SendReq(t, ListenerType, 0, 0) 167 envoy.SendReq(t, EndpointType, 1, 2) 168 169 // And should get a response immediately. 170 assertResponseSent(t, envoy.stream.sendCh, expectListenerJSON(t, snap, "", 1, 3)) 171 172 // Now send Route request along with next listener one 173 envoy.SendReq(t, RouteType, 0, 0) 174 envoy.SendReq(t, ListenerType, 1, 3) 175 176 // We don't serve routes yet so this should block with no response 177 assertChanBlocked(t, envoy.stream.sendCh) 178 179 // WOOP! Envoy now has full connect config. Lets verify that if we update it, 180 // all the responses get resent with the new version. We don't actually want 181 // to change everything because that's tedious - our implementation will 182 // actually resend all blocked types on the new "version" anyway since it 183 // doesn't know _what_ changed. We could do something trivial but let's 184 // simulate a leaf cert expiring and being rotated. 185 snap.Leaf = proxycfg.TestLeafForCA(t, snap.Roots.Roots[0]) 186 mgr.DeliverConfig(t, "web-sidecar-proxy", snap) 187 188 // All 3 response that have something to return should return with new version 189 // note that the ordering is not deterministic in general. Trying to make this 190 // test order-agnostic though is a massive pain since we are comparing 191 // non-identical JSON strings (so can simply sort by anything) and because we 192 // don't know the order the nonces will be assigned. For now we rely and 193 // require our implementation to always deliver updates in a specific order 194 // which is reasonable anyway to ensure consistency of the config Envoy sees. 195 assertResponseSent(t, envoy.stream.sendCh, expectClustersJSON(t, snap, "", 2, 4)) 196 assertResponseSent(t, envoy.stream.sendCh, expectEndpointsJSON(t, snap, "", 2, 5)) 197 assertResponseSent(t, envoy.stream.sendCh, expectListenerJSON(t, snap, "", 2, 6)) 198 199 // Let's pretend that Envoy doesn't like that new listener config. It will ACK 200 // all the others (same version) but NACK the listener. This is the most 201 // subtle part of xDS and the server implementation so I'll elaborate. A full 202 // description of the protocol can be found at 203 // https://github.com/envoyproxy/data-plane-api/blob/master/XDS_PROTOCOL.md. 204 // Envoy delays making a followup request for a type until after it has 205 // processed and applied the last response. The next request then will include 206 // the nonce in the last response which acknowledges _receiving_ and handling 207 // that response. It also includes the currently applied version. If all is 208 // good and it successfully applies the config, then the version in the next 209 // response will be the same version just sent. This is considered to be an 210 // ACK of that version for that type. If envoy fails to apply the config for 211 // some reason, it will still acknowledge that it received it (still return 212 // the responses nonce), but will show the previous version it's still using. 213 // This is considered a NACK. It's important that the server pay attention to 214 // the _nonce_ and not the version when deciding what to send otherwise a bad 215 // version that can't be applied in Envoy will cause a busy loop. 216 // 217 // In this case we are simulating that Envoy failed to apply the Listener 218 // response but did apply the other types so all get the new nonces, but 219 // listener stays on v1. 220 envoy.SendReq(t, ClusterType, 2, 4) 221 envoy.SendReq(t, EndpointType, 2, 5) 222 envoy.SendReq(t, ListenerType, 1, 6) // v1 is a NACK 223 224 // Even though we nacked, we should still NOT get then v2 listeners 225 // redelivered since nothing has changed. 226 assertChanBlocked(t, envoy.stream.sendCh) 227 228 // Change config again and make sure it's delivered to everyone! 229 snap.Leaf = proxycfg.TestLeafForCA(t, snap.Roots.Roots[0]) 230 mgr.DeliverConfig(t, "web-sidecar-proxy", snap) 231 232 assertResponseSent(t, envoy.stream.sendCh, expectClustersJSON(t, snap, "", 3, 7)) 233 assertResponseSent(t, envoy.stream.sendCh, expectEndpointsJSON(t, snap, "", 3, 8)) 234 assertResponseSent(t, envoy.stream.sendCh, expectListenerJSON(t, snap, "", 3, 9)) 235 } 236 237 func expectListenerJSONResources(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64) map[string]string { 238 tokenVal := "" 239 if token != "" { 240 tokenVal = fmt.Sprintf(",\n"+`"value": "%s"`, token) 241 } 242 return map[string]string{ 243 "public_listener": `{ 244 "@type": "type.googleapis.com/envoy.api.v2.Listener", 245 "name": "public_listener:0.0.0.0:9999", 246 "address": { 247 "socketAddress": { 248 "address": "0.0.0.0", 249 "portValue": 9999 250 } 251 }, 252 "filterChains": [ 253 { 254 "tlsContext": ` + expectedPublicTLSContextJSON(t, snap) + `, 255 "filters": [ 256 { 257 "name": "envoy.ext_authz", 258 "config": { 259 "grpc_service": { 260 "envoy_grpc": { 261 "cluster_name": "local_agent" 262 }, 263 "initial_metadata": [ 264 { 265 "key": "x-consul-token" 266 ` + tokenVal + ` 267 } 268 ] 269 }, 270 "stat_prefix": "connect_authz" 271 } 272 }, 273 { 274 "name": "envoy.tcp_proxy", 275 "config": { 276 "cluster": "local_app", 277 "stat_prefix": "public_listener" 278 } 279 } 280 ] 281 } 282 ] 283 }`, 284 "service:db": `{ 285 "@type": "type.googleapis.com/envoy.api.v2.Listener", 286 "name": "service:db:127.0.0.1:9191", 287 "address": { 288 "socketAddress": { 289 "address": "127.0.0.1", 290 "portValue": 9191 291 } 292 }, 293 "filterChains": [ 294 { 295 "filters": [ 296 { 297 "name": "envoy.tcp_proxy", 298 "config": { 299 "cluster": "service:db", 300 "stat_prefix": "service:db" 301 } 302 } 303 ] 304 } 305 ] 306 }`, 307 "prepared_query:geo-cache": `{ 308 "@type": "type.googleapis.com/envoy.api.v2.Listener", 309 "name": "prepared_query:geo-cache:127.10.10.10:8181", 310 "address": { 311 "socketAddress": { 312 "address": "127.10.10.10", 313 "portValue": 8181 314 } 315 }, 316 "filterChains": [ 317 { 318 "filters": [ 319 { 320 "name": "envoy.tcp_proxy", 321 "config": { 322 "cluster": "prepared_query:geo-cache", 323 "stat_prefix": "prepared_query:geo-cache" 324 } 325 } 326 ] 327 } 328 ] 329 }`, 330 } 331 } 332 333 func expectListenerJSONFromResources(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64, resourcesJSON map[string]string) string { 334 resJSON := "" 335 // Sort resources into specific order because that matters in JSONEq 336 // comparison later. 337 keyOrder := []string{"public_listener"} 338 for _, u := range snap.Proxy.Upstreams { 339 keyOrder = append(keyOrder, u.Identifier()) 340 } 341 for _, k := range keyOrder { 342 j, ok := resourcesJSON[k] 343 if !ok { 344 continue 345 } 346 if resJSON != "" { 347 resJSON += ",\n" 348 } 349 resJSON += j 350 } 351 return `{ 352 "versionInfo": "` + hexString(v) + `", 353 "resources": [` + resJSON + `], 354 "typeUrl": "type.googleapis.com/envoy.api.v2.Listener", 355 "nonce": "` + hexString(n) + `" 356 }` 357 } 358 359 func expectListenerJSON(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64) string { 360 return expectListenerJSONFromResources(t, snap, token, v, n, 361 expectListenerJSONResources(t, snap, token, v, n)) 362 } 363 364 func expectClustersJSONResources(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64) map[string]string { 365 return map[string]string{ 366 "local_app": ` 367 { 368 "@type": "type.googleapis.com/envoy.api.v2.Cluster", 369 "name": "local_app", 370 "connectTimeout": "5s", 371 "hosts": [ 372 { 373 "socketAddress": { 374 "address": "127.0.0.1", 375 "portValue": 8080 376 } 377 } 378 ] 379 }`, 380 "service:db": ` 381 { 382 "@type": "type.googleapis.com/envoy.api.v2.Cluster", 383 "name": "service:db", 384 "type": "EDS", 385 "edsClusterConfig": { 386 "edsConfig": { 387 "ads": { 388 389 } 390 } 391 }, 392 "outlierDetection": { 393 394 }, 395 "connectTimeout": "1s", 396 "tlsContext": ` + expectedUpstreamTLSContextJSON(t, snap) + ` 397 }`, 398 "prepared_query:geo-cache": ` 399 { 400 "@type": "type.googleapis.com/envoy.api.v2.Cluster", 401 "name": "prepared_query:geo-cache", 402 "type": "EDS", 403 "edsClusterConfig": { 404 "edsConfig": { 405 "ads": { 406 407 } 408 } 409 }, 410 "outlierDetection": { 411 412 }, 413 "connectTimeout": "5s", 414 "tlsContext": ` + expectedUpstreamTLSContextJSON(t, snap) + ` 415 }`, 416 } 417 } 418 419 func expectClustersJSONFromResources(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64, resourcesJSON map[string]string) string { 420 resJSON := "" 421 422 // Sort resources into specific order because that matters in JSONEq 423 // comparison later. 424 keyOrder := []string{"local_app"} 425 for _, u := range snap.Proxy.Upstreams { 426 keyOrder = append(keyOrder, u.Identifier()) 427 } 428 for _, k := range keyOrder { 429 j, ok := resourcesJSON[k] 430 if !ok { 431 continue 432 } 433 if resJSON != "" { 434 resJSON += ",\n" 435 } 436 resJSON += j 437 } 438 439 return `{ 440 "versionInfo": "` + hexString(v) + `", 441 "resources": [` + resJSON + `], 442 "typeUrl": "type.googleapis.com/envoy.api.v2.Cluster", 443 "nonce": "` + hexString(n) + `" 444 }` 445 } 446 447 func expectClustersJSON(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64) string { 448 return expectClustersJSONFromResources(t, snap, token, v, n, 449 expectClustersJSONResources(t, snap, token, v, n)) 450 } 451 452 func expectEndpointsJSON(t *testing.T, snap *proxycfg.ConfigSnapshot, token string, v, n uint64) string { 453 return `{ 454 "versionInfo": "` + hexString(v) + `", 455 "resources": [ 456 { 457 "@type": "type.googleapis.com/envoy.api.v2.ClusterLoadAssignment", 458 "clusterName": "service:db", 459 "endpoints": [ 460 { 461 "lbEndpoints": [ 462 { 463 "endpoint": { 464 "address": { 465 "socketAddress": { 466 "address": "10.10.1.1", 467 "portValue": 0 468 } 469 } 470 }, 471 "healthStatus": "HEALTHY", 472 "loadBalancingWeight": 1 473 }, 474 { 475 "endpoint": { 476 "address": { 477 "socketAddress": { 478 "address": "10.10.1.2", 479 "portValue": 0 480 } 481 } 482 }, 483 "healthStatus": "HEALTHY", 484 "loadBalancingWeight": 1 485 } 486 ] 487 } 488 ] 489 } 490 ], 491 "typeUrl": "type.googleapis.com/envoy.api.v2.ClusterLoadAssignment", 492 "nonce": "` + hexString(n) + `" 493 }` 494 } 495 496 func expectedUpstreamTLSContextJSON(t *testing.T, snap *proxycfg.ConfigSnapshot) string { 497 return expectedTLSContextJSON(t, snap, false) 498 } 499 500 func expectedPublicTLSContextJSON(t *testing.T, snap *proxycfg.ConfigSnapshot) string { 501 return expectedTLSContextJSON(t, snap, true) 502 } 503 504 func expectedTLSContextJSON(t *testing.T, snap *proxycfg.ConfigSnapshot, requireClientCert bool) string { 505 // Assume just one root for now, can get fancier later if needed. 506 caPEM := snap.Roots.Roots[0].RootCert 507 reqClient := "" 508 if requireClientCert { 509 reqClient = `, 510 "requireClientCertificate": true` 511 } 512 return `{ 513 "commonTlsContext": { 514 "tlsParams": {}, 515 "tlsCertificates": [ 516 { 517 "certificateChain": { 518 "inlineString": "` + strings.Replace(snap.Leaf.CertPEM, "\n", "\\n", -1) + `" 519 }, 520 "privateKey": { 521 "inlineString": "` + strings.Replace(snap.Leaf.PrivateKeyPEM, "\n", "\\n", -1) + `" 522 } 523 } 524 ], 525 "validationContext": { 526 "trustedCa": { 527 "inlineString": "` + strings.Replace(caPEM, "\n", "\\n", -1) + `" 528 } 529 } 530 } 531 ` + reqClient + ` 532 }` 533 } 534 535 func assertChanBlocked(t *testing.T, ch chan *envoy.DiscoveryResponse) { 536 t.Helper() 537 select { 538 case r := <-ch: 539 t.Fatalf("chan should block but received: %v", r) 540 case <-time.After(10 * time.Millisecond): 541 return 542 } 543 } 544 545 func assertResponseSent(t *testing.T, ch chan *envoy.DiscoveryResponse, wantJSON string) { 546 t.Helper() 547 select { 548 case r := <-ch: 549 assertResponse(t, r, wantJSON) 550 case <-time.After(50 * time.Millisecond): 551 t.Fatalf("no response received after 50ms") 552 } 553 } 554 555 // assertResponse is a helper to test a envoy.DiscoveryResponse matches the 556 // JSON representation we expect. We use JSON because the responses use protobuf 557 // Any type which includes binary protobuf encoding and would make creating 558 // expected structs require the same code that is under test! 559 func assertResponse(t *testing.T, r *envoy.DiscoveryResponse, wantJSON string) { 560 t.Helper() 561 m := jsonpb.Marshaler{ 562 Indent: " ", 563 } 564 gotJSON, err := m.MarshalToString(r) 565 require.NoError(t, err) 566 require.JSONEqf(t, wantJSON, gotJSON, "got:\n%s", gotJSON) 567 } 568 569 func TestServer_StreamAggregatedResources_ACLEnforcement(t *testing.T) { 570 571 tests := []struct { 572 name string 573 defaultDeny bool 574 acl string 575 token string 576 wantDenied bool 577 }{ 578 // Note that although we've stubbed actual ACL checks in the testManager 579 // ConnectAuthorize mock, by asserting against specific reason strings here 580 // even in the happy case which can't match the default one returned by the 581 // mock we are implicitly validating that the implementation used the 582 // correct token from the context. 583 { 584 name: "no ACLs configured", 585 defaultDeny: false, 586 wantDenied: false, 587 }, 588 { 589 name: "default deny, no token", 590 defaultDeny: true, 591 wantDenied: true, 592 }, 593 { 594 name: "default deny, service:write token", 595 defaultDeny: true, 596 acl: `service "web" { policy = "write" }`, 597 token: "service-write-on-web", 598 wantDenied: false, 599 }, 600 { 601 name: "default deny, service:read token", 602 defaultDeny: true, 603 acl: `service "web" { policy = "read" }`, 604 token: "service-write-on-web", 605 wantDenied: true, 606 }, 607 { 608 name: "default deny, service:write token on different service", 609 defaultDeny: true, 610 acl: `service "not-web" { policy = "write" }`, 611 token: "service-write-on-not-web", 612 wantDenied: true, 613 }, 614 } 615 616 for _, tt := range tests { 617 t.Run(tt.name, func(t *testing.T) { 618 logger := log.New(os.Stderr, "", log.LstdFlags) 619 mgr := newTestManager(t) 620 aclResolve := func(id string) (acl.Authorizer, error) { 621 if !tt.defaultDeny { 622 // Allow all 623 return acl.RootAuthorizer("allow"), nil 624 } 625 if tt.acl == "" { 626 // No token and defaultDeny is denied 627 return acl.RootAuthorizer("deny"), nil 628 } 629 // Ensure the correct token was passed 630 require.Equal(t, tt.token, id) 631 // Parse the ACL and enforce it 632 policy, err := acl.NewPolicyFromSource("", 0, tt.acl, acl.SyntaxLegacy, nil) 633 require.NoError(t, err) 634 return acl.NewPolicyAuthorizer(acl.RootAuthorizer("deny"), []*acl.Policy{policy}, nil) 635 } 636 envoy := NewTestEnvoy(t, "web-sidecar-proxy", tt.token) 637 defer envoy.Close() 638 639 s := Server{ 640 Logger: logger, 641 CfgMgr: mgr, 642 Authz: mgr, 643 ResolveToken: aclResolve, 644 } 645 s.Initialize() 646 647 errCh := make(chan error, 1) 648 go func() { 649 errCh <- s.StreamAggregatedResources(envoy.stream) 650 }() 651 652 // Register the proxy to create state needed to Watch() on 653 mgr.RegisterProxy(t, "web-sidecar-proxy") 654 655 // Deliver a new snapshot 656 snap := proxycfg.TestConfigSnapshot(t) 657 mgr.DeliverConfig(t, "web-sidecar-proxy", snap) 658 659 // Send initial listener discover, in real life Envoy always sends cluster 660 // first but it doesn't really matter and listener has a response that 661 // includes the token in the ext authz filter so lets us test more stuff. 662 envoy.SendReq(t, ListenerType, 0, 0) 663 664 if !tt.wantDenied { 665 assertResponseSent(t, envoy.stream.sendCh, expectListenerJSON(t, snap, tt.token, 1, 1)) 666 // Close the client stream since all is well. We _don't_ do this in the 667 // expected error case because we want to verify the error closes the 668 // stream from server side. 669 envoy.Close() 670 } 671 672 select { 673 case err := <-errCh: 674 if tt.wantDenied { 675 require.Error(t, err) 676 require.Contains(t, err.Error(), "permission denied") 677 mgr.AssertWatchCancelled(t, "web-sidecar-proxy") 678 } else { 679 require.NoError(t, err) 680 } 681 case <-time.After(50 * time.Millisecond): 682 t.Fatalf("timed out waiting for handler to finish") 683 } 684 }) 685 } 686 } 687 688 func TestServer_StreamAggregatedResources_ACLTokenDeleted_StreamTerminatedDuringDiscoveryRequest(t *testing.T) { 689 aclRules := `service "web" { policy = "write" }` 690 token := "service-write-on-web" 691 692 policy, err := acl.NewPolicyFromSource("", 0, aclRules, acl.SyntaxLegacy, nil) 693 require.NoError(t, err) 694 695 var validToken atomic.Value 696 validToken.Store(token) 697 698 logger := log.New(os.Stderr, "", log.LstdFlags) 699 mgr := newTestManager(t) 700 aclResolve := func(id string) (acl.Authorizer, error) { 701 if token := validToken.Load(); token == nil || id != token.(string) { 702 return nil, acl.ErrNotFound 703 } 704 705 return acl.NewPolicyAuthorizer(acl.RootAuthorizer("deny"), []*acl.Policy{policy}, nil) 706 } 707 envoy := NewTestEnvoy(t, "web-sidecar-proxy", token) 708 defer envoy.Close() 709 710 s := Server{ 711 Logger: logger, 712 CfgMgr: mgr, 713 Authz: mgr, 714 ResolveToken: aclResolve, 715 AuthCheckFrequency: 1 * time.Hour, // make sure this doesn't kick in 716 } 717 s.Initialize() 718 719 errCh := make(chan error, 1) 720 go func() { 721 errCh <- s.StreamAggregatedResources(envoy.stream) 722 }() 723 724 getError := func() (gotErr error, ok bool) { 725 select { 726 case err := <-errCh: 727 return err, true 728 default: 729 return nil, false 730 } 731 } 732 733 // Register the proxy to create state needed to Watch() on 734 mgr.RegisterProxy(t, "web-sidecar-proxy") 735 736 // Send initial cluster discover (OK) 737 envoy.SendReq(t, ClusterType, 0, 0) 738 { 739 err, ok := getError() 740 require.NoError(t, err) 741 require.False(t, ok) 742 } 743 744 // Check no response sent yet 745 assertChanBlocked(t, envoy.stream.sendCh) 746 { 747 err, ok := getError() 748 require.NoError(t, err) 749 require.False(t, ok) 750 } 751 752 // Deliver a new snapshot 753 snap := proxycfg.TestConfigSnapshot(t) 754 mgr.DeliverConfig(t, "web-sidecar-proxy", snap) 755 756 assertResponseSent(t, envoy.stream.sendCh, expectClustersJSON(t, snap, token, 1, 1)) 757 758 // Now nuke the ACL token. 759 validToken.Store("") 760 761 // It also (in parallel) issues the next cluster request (which acts as an ACK 762 // of the version we sent) 763 envoy.SendReq(t, ClusterType, 1, 1) 764 765 select { 766 case err := <-errCh: 767 require.Error(t, err) 768 gerr, ok := status.FromError(err) 769 require.Truef(t, ok, "not a grpc status error: type='%T' value=%v", err, err) 770 require.Equal(t, codes.Unauthenticated, gerr.Code()) 771 require.Equal(t, "unauthenticated: ACL not found", gerr.Message()) 772 773 mgr.AssertWatchCancelled(t, "web-sidecar-proxy") 774 case <-time.After(50 * time.Millisecond): 775 t.Fatalf("timed out waiting for handler to finish") 776 } 777 } 778 779 func TestServer_StreamAggregatedResources_ACLTokenDeleted_StreamTerminatedInBackground(t *testing.T) { 780 aclRules := `service "web" { policy = "write" }` 781 token := "service-write-on-web" 782 783 policy, err := acl.NewPolicyFromSource("", 0, aclRules, acl.SyntaxLegacy, nil) 784 require.NoError(t, err) 785 786 var validToken atomic.Value 787 validToken.Store(token) 788 789 logger := log.New(os.Stderr, "", log.LstdFlags) 790 mgr := newTestManager(t) 791 aclResolve := func(id string) (acl.Authorizer, error) { 792 if token := validToken.Load(); token == nil || id != token.(string) { 793 return nil, acl.ErrNotFound 794 } 795 796 return acl.NewPolicyAuthorizer(acl.RootAuthorizer("deny"), []*acl.Policy{policy}, nil) 797 } 798 envoy := NewTestEnvoy(t, "web-sidecar-proxy", token) 799 defer envoy.Close() 800 801 s := Server{ 802 Logger: logger, 803 CfgMgr: mgr, 804 Authz: mgr, 805 ResolveToken: aclResolve, 806 AuthCheckFrequency: 100 * time.Millisecond, // Make this short. 807 } 808 s.Initialize() 809 810 errCh := make(chan error, 1) 811 go func() { 812 errCh <- s.StreamAggregatedResources(envoy.stream) 813 }() 814 815 getError := func() (gotErr error, ok bool) { 816 select { 817 case err := <-errCh: 818 return err, true 819 default: 820 return nil, false 821 } 822 } 823 824 // Register the proxy to create state needed to Watch() on 825 mgr.RegisterProxy(t, "web-sidecar-proxy") 826 827 // Send initial cluster discover (OK) 828 envoy.SendReq(t, ClusterType, 0, 0) 829 { 830 err, ok := getError() 831 require.NoError(t, err) 832 require.False(t, ok) 833 } 834 835 // Check no response sent yet 836 assertChanBlocked(t, envoy.stream.sendCh) 837 { 838 err, ok := getError() 839 require.NoError(t, err) 840 require.False(t, ok) 841 } 842 843 // Deliver a new snapshot 844 snap := proxycfg.TestConfigSnapshot(t) 845 mgr.DeliverConfig(t, "web-sidecar-proxy", snap) 846 847 assertResponseSent(t, envoy.stream.sendCh, expectClustersJSON(t, snap, token, 1, 1)) 848 849 // It also (in parallel) issues the next cluster request (which acts as an ACK 850 // of the version we sent) 851 envoy.SendReq(t, ClusterType, 1, 1) 852 853 // Check no response sent yet 854 assertChanBlocked(t, envoy.stream.sendCh) 855 { 856 err, ok := getError() 857 require.NoError(t, err) 858 require.False(t, ok) 859 } 860 861 // Now nuke the ACL token while there's no activity. 862 validToken.Store("") 863 864 select { 865 case err := <-errCh: 866 require.Error(t, err) 867 gerr, ok := status.FromError(err) 868 require.Truef(t, ok, "not a grpc status error: type='%T' value=%v", err, err) 869 require.Equal(t, codes.Unauthenticated, gerr.Code()) 870 require.Equal(t, "unauthenticated: ACL not found", gerr.Message()) 871 872 mgr.AssertWatchCancelled(t, "web-sidecar-proxy") 873 case <-time.After(200 * time.Millisecond): 874 t.Fatalf("timed out waiting for handler to finish") 875 } 876 } 877 878 // This tests the ext_authz service method that implements connect authz. 879 func TestServer_Check(t *testing.T) { 880 881 tests := []struct { 882 name string 883 source string 884 dest string 885 sourcePrincipal string 886 destPrincipal string 887 authzResult connectAuthzResult 888 wantErr bool 889 wantErrCode codes.Code 890 wantDenied bool 891 wantReason string 892 }{ 893 { 894 name: "auth allowed", 895 source: "web", 896 dest: "db", 897 authzResult: connectAuthzResult{true, "default allow", nil, nil}, 898 wantDenied: false, 899 wantReason: "default allow", 900 }, 901 { 902 name: "auth denied", 903 source: "web", 904 dest: "db", 905 authzResult: connectAuthzResult{false, "default deny", nil, nil}, 906 wantDenied: true, 907 wantReason: "default deny", 908 }, 909 { 910 name: "no source", 911 sourcePrincipal: "", 912 dest: "db", 913 // Should never make it to authz call. 914 wantErr: true, 915 wantErrCode: codes.InvalidArgument, 916 }, 917 { 918 name: "no dest", 919 source: "web", 920 dest: "", 921 // Should never make it to authz call. 922 wantErr: true, 923 wantErrCode: codes.InvalidArgument, 924 }, 925 { 926 name: "dest invalid format", 927 source: "web", 928 destPrincipal: "not-a-spiffe-id", 929 // Should never make it to authz call. 930 wantDenied: true, 931 wantReason: "Destination Principal is not a valid Connect identity", 932 }, 933 { 934 name: "dest not a service URI", 935 source: "web", 936 destPrincipal: "spiffe://trust-domain.consul", 937 // Should never make it to authz call. 938 wantDenied: true, 939 wantReason: "Destination Principal is not a valid Service identity", 940 }, 941 { 942 name: "ACL not got permission for authz call", 943 source: "web", 944 dest: "db", 945 authzResult: connectAuthzResult{false, "", nil, acl.ErrPermissionDenied}, 946 wantErr: true, 947 wantErrCode: codes.PermissionDenied, 948 }, 949 { 950 name: "Random error running authz", 951 source: "web", 952 dest: "db", 953 authzResult: connectAuthzResult{false, "", nil, errors.New("gremlin attack")}, 954 wantErr: true, 955 wantErrCode: codes.Internal, 956 }, 957 } 958 959 for _, tt := range tests { 960 t.Run(tt.name, func(t *testing.T) { 961 token := "my-real-acl-token" 962 logger := log.New(os.Stderr, "", log.LstdFlags) 963 mgr := newTestManager(t) 964 965 // Setup expected auth result against that token no lock as no other 966 // goroutine is touching this yet. 967 mgr.authz[token] = tt.authzResult 968 969 aclResolve := func(id string) (acl.Authorizer, error) { 970 return nil, nil 971 } 972 envoy := NewTestEnvoy(t, "web-sidecar-proxy", token) 973 defer envoy.Close() 974 975 s := Server{ 976 Logger: logger, 977 CfgMgr: mgr, 978 Authz: mgr, 979 ResolveToken: aclResolve, 980 } 981 s.Initialize() 982 983 // Create a context with the correct token 984 ctx := metadata.NewIncomingContext(context.Background(), 985 metadata.Pairs("x-consul-token", token)) 986 987 r := TestCheckRequest(t, tt.source, tt.dest) 988 // If sourcePrincipal is set override, or if source is also not set 989 // explicitly override to empty. 990 if tt.sourcePrincipal != "" || tt.source == "" { 991 r.Attributes.Source.Principal = tt.sourcePrincipal 992 } 993 if tt.destPrincipal != "" || tt.dest == "" { 994 r.Attributes.Destination.Principal = tt.destPrincipal 995 } 996 resp, err := s.Check(ctx, r) 997 // Denied is not an error 998 if tt.wantErr { 999 require.Error(t, err) 1000 grpcStatus := status.Convert(err) 1001 require.Equal(t, tt.wantErrCode, grpcStatus.Code()) 1002 require.Nil(t, resp) 1003 return 1004 } 1005 require.NoError(t, err) 1006 if tt.wantDenied { 1007 require.Equal(t, int32(codes.PermissionDenied), resp.Status.Code) 1008 } else { 1009 require.Equal(t, int32(codes.OK), resp.Status.Code) 1010 } 1011 require.Contains(t, resp.Status.Message, tt.wantReason) 1012 }) 1013 } 1014 } 1015 1016 func TestServer_ConfigOverridesListeners(t *testing.T) { 1017 1018 tests := []struct { 1019 name string 1020 setup func(snap *proxycfg.ConfigSnapshot) string 1021 }{ 1022 { 1023 name: "sanity check no custom", 1024 setup: func(snap *proxycfg.ConfigSnapshot) string { 1025 // Default snap and expectation 1026 return expectListenerJSON(t, snap, "my-token", 1, 1) 1027 }, 1028 }, 1029 { 1030 name: "custom public_listener no type", 1031 setup: func(snap *proxycfg.ConfigSnapshot) string { 1032 snap.Proxy.Config["envoy_public_listener_json"] = 1033 customListenerJSON(t, customListenerJSONOptions{ 1034 Name: "custom-public-listen", 1035 IncludeType: false, 1036 }) 1037 resources := expectListenerJSONResources(t, snap, "my-token", 1, 1) 1038 1039 // Replace the public listener with the custom one WITH type since 1040 // that's how it comes out the other end, and with TLS and authz 1041 // overridden. 1042 resources["public_listener"] = customListenerJSON(t, customListenerJSONOptions{ 1043 Name: "custom-public-listen", 1044 // We should add type, TLS and authz 1045 IncludeType: true, 1046 OverrideAuthz: true, 1047 TLSContext: expectedPublicTLSContextJSON(t, snap), 1048 }) 1049 return expectListenerJSONFromResources(t, snap, "my-token", 1, 1, resources) 1050 }, 1051 }, 1052 { 1053 name: "custom public_listener with type", 1054 setup: func(snap *proxycfg.ConfigSnapshot) string { 1055 snap.Proxy.Config["envoy_public_listener_json"] = 1056 customListenerJSON(t, customListenerJSONOptions{ 1057 Name: "custom-public-listen", 1058 IncludeType: true, 1059 }) 1060 resources := expectListenerJSONResources(t, snap, "my-token", 1, 1) 1061 1062 // Replace the public listener with the custom one WITH type since 1063 // that's how it comes out the other end, and with TLS and authz 1064 // overridden. 1065 resources["public_listener"] = customListenerJSON(t, customListenerJSONOptions{ 1066 Name: "custom-public-listen", 1067 // We should add type, TLS and authz 1068 IncludeType: true, 1069 OverrideAuthz: true, 1070 TLSContext: expectedPublicTLSContextJSON(t, snap), 1071 }) 1072 return expectListenerJSONFromResources(t, snap, "my-token", 1, 1, resources) 1073 }, 1074 }, 1075 { 1076 name: "custom public_listener with TLS should be overridden", 1077 setup: func(snap *proxycfg.ConfigSnapshot) string { 1078 snap.Proxy.Config["envoy_public_listener_json"] = 1079 customListenerJSON(t, customListenerJSONOptions{ 1080 Name: "custom-public-listen", 1081 IncludeType: true, 1082 TLSContext: `{"requireClientCertificate": false}`, 1083 }) 1084 resources := expectListenerJSONResources(t, snap, "my-token", 1, 1) 1085 1086 // Replace the public listener with the custom one WITH type since 1087 // that's how it comes out the other end, and with TLS and authz 1088 // overridden. 1089 resources["public_listener"] = customListenerJSON(t, customListenerJSONOptions{ 1090 Name: "custom-public-listen", 1091 // We should add type, TLS and authz 1092 IncludeType: true, 1093 OverrideAuthz: true, 1094 TLSContext: expectedPublicTLSContextJSON(t, snap), 1095 }) 1096 return expectListenerJSONFromResources(t, snap, "my-token", 1, 1, resources) 1097 }, 1098 }, 1099 { 1100 name: "custom upstream no type", 1101 setup: func(snap *proxycfg.ConfigSnapshot) string { 1102 snap.Proxy.Upstreams[0].Config["envoy_listener_json"] = 1103 customListenerJSON(t, customListenerJSONOptions{ 1104 Name: "custom-upstream", 1105 IncludeType: false, 1106 }) 1107 resources := expectListenerJSONResources(t, snap, "my-token", 1, 1) 1108 1109 // Replace an upstream listener with the custom one WITH type since 1110 // that's how it comes out the other end. Note we do override TLS 1111 resources["service:db"] = 1112 customListenerJSON(t, customListenerJSONOptions{ 1113 Name: "custom-upstream", 1114 // We should add type 1115 IncludeType: true, 1116 }) 1117 return expectListenerJSONFromResources(t, snap, "my-token", 1, 1, resources) 1118 }, 1119 }, 1120 { 1121 name: "custom upstream with type", 1122 setup: func(snap *proxycfg.ConfigSnapshot) string { 1123 snap.Proxy.Upstreams[0].Config["envoy_listener_json"] = 1124 customListenerJSON(t, customListenerJSONOptions{ 1125 Name: "custom-upstream", 1126 IncludeType: true, 1127 }) 1128 resources := expectListenerJSONResources(t, snap, "my-token", 1, 1) 1129 1130 // Replace an upstream listener with the custom one WITH type since 1131 // that's how it comes out the other end. 1132 resources["service:db"] = 1133 customListenerJSON(t, customListenerJSONOptions{ 1134 Name: "custom-upstream", 1135 // We should add type 1136 IncludeType: true, 1137 }) 1138 return expectListenerJSONFromResources(t, snap, "my-token", 1, 1, resources) 1139 }, 1140 }, 1141 } 1142 1143 for _, tt := range tests { 1144 t.Run(tt.name, func(t *testing.T) { 1145 require := require.New(t) 1146 1147 // Sanity check default with no overrides first 1148 snap := proxycfg.TestConfigSnapshot(t) 1149 expect := tt.setup(snap) 1150 1151 listeners, err := listenersFromSnapshot(snap, "my-token") 1152 require.NoError(err) 1153 r, err := createResponse(ListenerType, "00000001", "00000001", listeners) 1154 require.NoError(err) 1155 1156 assertResponse(t, r, expect) 1157 }) 1158 } 1159 } 1160 1161 func TestServer_ConfigOverridesClusters(t *testing.T) { 1162 1163 tests := []struct { 1164 name string 1165 setup func(snap *proxycfg.ConfigSnapshot) string 1166 }{ 1167 { 1168 name: "sanity check no custom", 1169 setup: func(snap *proxycfg.ConfigSnapshot) string { 1170 // Default snap and expectation 1171 return expectClustersJSON(t, snap, "my-token", 1, 1) 1172 }, 1173 }, 1174 { 1175 name: "custom public with no type", 1176 setup: func(snap *proxycfg.ConfigSnapshot) string { 1177 snap.Proxy.Config["envoy_local_cluster_json"] = 1178 customAppClusterJSON(t, customClusterJSONOptions{ 1179 Name: "mylocal", 1180 IncludeType: false, 1181 }) 1182 resources := expectClustersJSONResources(t, snap, "my-token", 1, 1) 1183 1184 // Replace an upstream listener with the custom one WITH type since 1185 // that's how it comes out the other end. 1186 resources["local_app"] = 1187 customAppClusterJSON(t, customClusterJSONOptions{ 1188 Name: "mylocal", 1189 IncludeType: true, 1190 }) 1191 return expectClustersJSONFromResources(t, snap, "my-token", 1, 1, resources) 1192 }, 1193 }, 1194 { 1195 name: "custom public with type", 1196 setup: func(snap *proxycfg.ConfigSnapshot) string { 1197 snap.Proxy.Config["envoy_local_cluster_json"] = 1198 customAppClusterJSON(t, customClusterJSONOptions{ 1199 Name: "mylocal", 1200 IncludeType: true, 1201 }) 1202 resources := expectClustersJSONResources(t, snap, "my-token", 1, 1) 1203 1204 // Replace an upstream listener with the custom one WITH type since 1205 // that's how it comes out the other end. 1206 resources["local_app"] = 1207 customAppClusterJSON(t, customClusterJSONOptions{ 1208 Name: "mylocal", 1209 IncludeType: true, 1210 }) 1211 return expectClustersJSONFromResources(t, snap, "my-token", 1, 1, resources) 1212 }, 1213 }, 1214 { 1215 name: "custom upstream with no type", 1216 setup: func(snap *proxycfg.ConfigSnapshot) string { 1217 snap.Proxy.Upstreams[0].Config["envoy_cluster_json"] = 1218 customEDSClusterJSON(t, customClusterJSONOptions{ 1219 Name: "myservice", 1220 IncludeType: false, 1221 }) 1222 resources := expectClustersJSONResources(t, snap, "my-token", 1, 1) 1223 1224 // Replace an upstream listener with the custom one WITH type since 1225 // that's how it comes out the other end. 1226 resources["service:db"] = 1227 customEDSClusterJSON(t, customClusterJSONOptions{ 1228 Name: "myservice", 1229 IncludeType: true, 1230 TLSContext: expectedUpstreamTLSContextJSON(t, snap), 1231 }) 1232 return expectClustersJSONFromResources(t, snap, "my-token", 1, 1, resources) 1233 }, 1234 }, 1235 { 1236 name: "custom upstream with type", 1237 setup: func(snap *proxycfg.ConfigSnapshot) string { 1238 snap.Proxy.Upstreams[0].Config["envoy_cluster_json"] = 1239 customEDSClusterJSON(t, customClusterJSONOptions{ 1240 Name: "myservice", 1241 IncludeType: true, 1242 }) 1243 resources := expectClustersJSONResources(t, snap, "my-token", 1, 1) 1244 1245 // Replace an upstream listener with the custom one WITH type since 1246 // that's how it comes out the other end. 1247 resources["service:db"] = 1248 customEDSClusterJSON(t, customClusterJSONOptions{ 1249 Name: "myservice", 1250 IncludeType: true, 1251 TLSContext: expectedUpstreamTLSContextJSON(t, snap), 1252 }) 1253 return expectClustersJSONFromResources(t, snap, "my-token", 1, 1, resources) 1254 }, 1255 }, 1256 } 1257 1258 for _, tt := range tests { 1259 t.Run(tt.name, func(t *testing.T) { 1260 require := require.New(t) 1261 1262 // Sanity check default with no overrides first 1263 snap := proxycfg.TestConfigSnapshot(t) 1264 expect := tt.setup(snap) 1265 1266 clusters, err := clustersFromSnapshot(snap, "my-token") 1267 require.NoError(err) 1268 r, err := createResponse(ClusterType, "00000001", "00000001", clusters) 1269 require.NoError(err) 1270 1271 fmt.Println(r) 1272 1273 assertResponse(t, r, expect) 1274 }) 1275 } 1276 } 1277 1278 type customListenerJSONOptions struct { 1279 Name string 1280 IncludeType bool 1281 OverrideAuthz bool 1282 TLSContext string 1283 } 1284 1285 const customListenerJSONTpl = `{ 1286 {{ if .IncludeType -}} 1287 "@type": "type.googleapis.com/envoy.api.v2.Listener", 1288 {{- end }} 1289 "name": "{{ .Name }}", 1290 "address": { 1291 "socketAddress": { 1292 "address": "11.11.11.11", 1293 "portValue": 11111 1294 } 1295 }, 1296 "filterChains": [ 1297 { 1298 {{ if .TLSContext -}} 1299 "tlsContext": {{ .TLSContext }}, 1300 {{- end }} 1301 "filters": [ 1302 {{ if .OverrideAuthz -}} 1303 { 1304 "name": "envoy.ext_authz", 1305 "config": { 1306 "grpc_service": { 1307 "envoy_grpc": { 1308 "cluster_name": "local_agent" 1309 }, 1310 "initial_metadata": [ 1311 { 1312 "key": "x-consul-token", 1313 "value": "my-token" 1314 } 1315 ] 1316 }, 1317 "stat_prefix": "connect_authz" 1318 } 1319 }, 1320 {{- end }} 1321 { 1322 "name": "envoy.tcp_proxy", 1323 "config": { 1324 "cluster": "random-cluster", 1325 "stat_prefix": "foo-stats" 1326 } 1327 } 1328 ] 1329 } 1330 ] 1331 }` 1332 1333 var customListenerJSONTemplate = template.Must(template.New("").Parse(customListenerJSONTpl)) 1334 1335 func customListenerJSON(t *testing.T, opts customListenerJSONOptions) string { 1336 t.Helper() 1337 var buf bytes.Buffer 1338 err := customListenerJSONTemplate.Execute(&buf, opts) 1339 require.NoError(t, err) 1340 return buf.String() 1341 } 1342 1343 type customClusterJSONOptions struct { 1344 Name string 1345 IncludeType bool 1346 TLSContext string 1347 } 1348 1349 var customEDSClusterJSONTpl = `{ 1350 {{ if .IncludeType -}} 1351 "@type": "type.googleapis.com/envoy.api.v2.Cluster", 1352 {{- end }} 1353 {{ if .TLSContext -}} 1354 "tlsContext": {{ .TLSContext }}, 1355 {{- end }} 1356 "name": "{{ .Name }}", 1357 "type": "EDS", 1358 "edsClusterConfig": { 1359 "edsConfig": { 1360 "ads": { 1361 1362 } 1363 } 1364 }, 1365 "connectTimeout": "5s" 1366 }` 1367 1368 var customEDSClusterJSONTemplate = template.Must(template.New("").Parse(customEDSClusterJSONTpl)) 1369 1370 func customEDSClusterJSON(t *testing.T, opts customClusterJSONOptions) string { 1371 t.Helper() 1372 var buf bytes.Buffer 1373 err := customEDSClusterJSONTemplate.Execute(&buf, opts) 1374 require.NoError(t, err) 1375 return buf.String() 1376 } 1377 1378 var customAppClusterJSONTpl = `{ 1379 {{ if .IncludeType -}} 1380 "@type": "type.googleapis.com/envoy.api.v2.Cluster", 1381 {{- end }} 1382 {{ if .TLSContext -}} 1383 "tlsContext": {{ .TLSContext }}, 1384 {{- end }} 1385 "name": "{{ .Name }}", 1386 "connectTimeout": "5s", 1387 "hosts": [ 1388 { 1389 "socketAddress": { 1390 "address": "127.0.0.1", 1391 "portValue": 8080 1392 } 1393 } 1394 ] 1395 }` 1396 1397 var customAppClusterJSONTemplate = template.Must(template.New("").Parse(customAppClusterJSONTpl)) 1398 1399 func customAppClusterJSON(t *testing.T, opts customClusterJSONOptions) string { 1400 t.Helper() 1401 var buf bytes.Buffer 1402 err := customAppClusterJSONTemplate.Execute(&buf, opts) 1403 require.NoError(t, err) 1404 return buf.String() 1405 }