github.com/hashicorp/nomad/api@v0.0.0-20240306165712-3193ac204f65/api_test.go (about) 1 // Copyright (c) HashiCorp, Inc. 2 // SPDX-License-Identifier: MPL-2.0 3 4 package api 5 6 import ( 7 "bytes" 8 "compress/gzip" 9 "context" 10 "encoding/json" 11 "errors" 12 "fmt" 13 "io" 14 "net/http" 15 "net/http/httptest" 16 "net/url" 17 "strings" 18 "testing" 19 "time" 20 21 "github.com/hashicorp/nomad/api/internal/testutil" 22 "github.com/shoenig/test/must" 23 ) 24 25 type configCallback func(c *Config) 26 27 func makeACLClient(t *testing.T, cb1 configCallback, 28 cb2 testutil.ServerConfigCallback) (*Client, *testutil.TestServer, *ACLToken) { 29 client, server := makeClient(t, cb1, func(c *testutil.TestServerConfig) { 30 c.ACL.Enabled = true 31 if cb2 != nil { 32 cb2(c) 33 } 34 }) 35 36 // Get the root token 37 root, _, err := client.ACLTokens().Bootstrap(nil) 38 if err != nil { 39 t.Fatalf("failed to bootstrap ACLs: %v", err) 40 } 41 client.SetSecretID(root.SecretID) 42 return client, server, root 43 } 44 45 func makeClient(t *testing.T, cb1 configCallback, 46 cb2 testutil.ServerConfigCallback) (*Client, *testutil.TestServer) { 47 // Make client config 48 conf := DefaultConfig() 49 if cb1 != nil { 50 cb1(conf) 51 } 52 53 // Create server 54 server := testutil.NewTestServer(t, cb2) 55 conf.Address = "http://" + server.HTTPAddr 56 57 // Create client 58 client, err := NewClient(conf) 59 if err != nil { 60 t.Fatalf("err: %v", err) 61 } 62 63 return client, server 64 } 65 66 func TestRequestTime(t *testing.T) { 67 testutil.Parallel(t) 68 69 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 70 time.Sleep(100 * time.Millisecond) 71 d, err := json.Marshal(struct{ Done bool }{true}) 72 if err != nil { 73 http.Error(w, err.Error(), http.StatusInternalServerError) 74 return 75 } 76 _, _ = w.Write(d) 77 })) 78 defer srv.Close() 79 80 conf := DefaultConfig() 81 conf.Address = srv.URL 82 83 client, err := NewClient(conf) 84 if err != nil { 85 t.Fatalf("err: %v", err) 86 } 87 88 var out interface{} 89 90 qm, err := client.query("/", &out, nil) 91 if err != nil { 92 t.Fatalf("query err: %v", err) 93 } 94 if qm.RequestTime == 0 { 95 t.Errorf("bad request time: %d", qm.RequestTime) 96 } 97 98 wm, err := client.put("/", struct{ S string }{"input"}, &out, nil) 99 if err != nil { 100 t.Fatalf("write err: %v", err) 101 } 102 if wm.RequestTime == 0 { 103 t.Errorf("bad request time: %d", wm.RequestTime) 104 } 105 106 wm, err = client.delete("/", nil, &out, nil) 107 if err != nil { 108 t.Fatalf("delete err: %v", err) 109 } 110 if wm.RequestTime == 0 { 111 t.Errorf("bad request time: %d", wm.RequestTime) 112 } 113 } 114 115 func TestDefaultConfig_env(t *testing.T) { 116 117 testURL := "http://1.2.3.4:5678" 118 auth := []string{"nomaduser", "12345"} 119 region := "test" 120 namespace := "dev" 121 token := "foobar" 122 123 t.Setenv("NOMAD_ADDR", testURL) 124 t.Setenv("NOMAD_REGION", region) 125 t.Setenv("NOMAD_NAMESPACE", namespace) 126 t.Setenv("NOMAD_HTTP_AUTH", strings.Join(auth, ":")) 127 t.Setenv("NOMAD_TOKEN", token) 128 129 config := DefaultConfig() 130 131 if config.Address != testURL { 132 t.Errorf("expected %q to be %q", config.Address, testURL) 133 } 134 135 if config.Region != region { 136 t.Errorf("expected %q to be %q", config.Region, region) 137 } 138 139 if config.Namespace != namespace { 140 t.Errorf("expected %q to be %q", config.Namespace, namespace) 141 } 142 143 if config.HttpAuth.Username != auth[0] { 144 t.Errorf("expected %q to be %q", config.HttpAuth.Username, auth[0]) 145 } 146 147 if config.HttpAuth.Password != auth[1] { 148 t.Errorf("expected %q to be %q", config.HttpAuth.Password, auth[1]) 149 } 150 151 if config.SecretID != token { 152 t.Errorf("Expected %q to be %q", config.SecretID, token) 153 } 154 } 155 156 func TestSetQueryOptions(t *testing.T) { 157 testutil.Parallel(t) 158 c, s := makeClient(t, nil, nil) 159 defer s.Stop() 160 161 r, _ := c.newRequest("GET", "/v1/jobs") 162 q := &QueryOptions{ 163 Region: "foo", 164 Namespace: "bar", 165 AllowStale: true, 166 WaitIndex: 1000, 167 WaitTime: 100 * time.Second, 168 AuthToken: "foobar", 169 Reverse: true, 170 } 171 r.setQueryOptions(q) 172 173 try := func(key, exp string) { 174 result := r.params.Get(key) 175 must.Eq(t, exp, result) 176 } 177 178 // Check auth token is set 179 must.Eq(t, "foobar", r.token) 180 181 // Check query parameters are set 182 try("region", "foo") 183 try("namespace", "bar") 184 try("stale", "") // should not be present 185 try("index", "1000") 186 try("wait", "100000ms") 187 try("reverse", "true") 188 } 189 190 func TestQueryOptionsContext(t *testing.T) { 191 testutil.Parallel(t) 192 ctx, cancel := context.WithCancel(context.Background()) 193 c, s := makeClient(t, nil, nil) 194 defer s.Stop() 195 q := (&QueryOptions{ 196 WaitIndex: 10000, 197 }).WithContext(ctx) 198 199 if q.ctx != ctx { 200 t.Fatalf("expected context to be set") 201 } 202 203 go func() { 204 cancel() 205 }() 206 _, _, err := c.Jobs().List(q) 207 if !errors.Is(err, context.Canceled) { 208 t.Fatalf("expected job wait to fail with canceled, got %s", err) 209 } 210 } 211 212 func TestWriteOptionsContext(t *testing.T) { 213 // No blocking query to test a real cancel of a pending request so 214 // just test that if we pass a pre-canceled context, writes fail quickly 215 testutil.Parallel(t) 216 217 c, err := NewClient(DefaultConfig()) 218 if err != nil { 219 t.Fatalf("failed to initialize client: %s", err) 220 } 221 222 ctx, cancel := context.WithCancel(context.Background()) 223 w := (&WriteOptions{}).WithContext(ctx) 224 225 if w.ctx != ctx { 226 t.Fatalf("expected context to be set") 227 } 228 229 cancel() 230 231 _, _, err = c.Jobs().Deregister("jobid", true, w) 232 if !errors.Is(err, context.Canceled) { 233 t.Fatalf("expected job to fail with canceled, got %s", err) 234 } 235 } 236 237 func TestSetWriteOptions(t *testing.T) { 238 testutil.Parallel(t) 239 c, s := makeClient(t, nil, nil) 240 defer s.Stop() 241 242 r, _ := c.newRequest("GET", "/v1/jobs") 243 q := &WriteOptions{ 244 Region: "foo", 245 Namespace: "bar", 246 AuthToken: "foobar", 247 IdempotencyToken: "idempotent", 248 } 249 r.setWriteOptions(q) 250 251 if r.params.Get("region") != "foo" { 252 t.Fatalf("bad: %v", r.params) 253 } 254 if r.params.Get("namespace") != "bar" { 255 t.Fatalf("bad: %v", r.params) 256 } 257 if r.params.Get("idempotency_token") != "idempotent" { 258 t.Fatalf("bad: %v", r.params) 259 } 260 if r.token != "foobar" { 261 t.Fatalf("bad: %v", r.token) 262 } 263 } 264 265 func TestRequestToHTTP(t *testing.T) { 266 testutil.Parallel(t) 267 c, s := makeClient(t, nil, nil) 268 defer s.Stop() 269 270 r, _ := c.newRequest("DELETE", "/v1/jobs/foo") 271 q := &QueryOptions{ 272 Region: "foo", 273 Namespace: "bar", 274 AuthToken: "foobar", 275 } 276 r.setQueryOptions(q) 277 req, err := r.toHTTP() 278 if err != nil { 279 t.Fatalf("err: %v", err) 280 } 281 282 if req.Method != "DELETE" { 283 t.Fatalf("bad: %v", req) 284 } 285 if req.URL.RequestURI() != "/v1/jobs/foo?namespace=bar®ion=foo" { 286 t.Fatalf("bad: %v", req) 287 } 288 if req.Header.Get("X-Nomad-Token") != "foobar" { 289 t.Fatalf("bad: %v", req) 290 } 291 } 292 293 func TestParseQueryMeta(t *testing.T) { 294 testutil.Parallel(t) 295 resp := &http.Response{ 296 Header: make(map[string][]string), 297 } 298 resp.Header.Set("X-Nomad-Index", "12345") 299 resp.Header.Set("X-Nomad-LastContact", "80") 300 resp.Header.Set("X-Nomad-KnownLeader", "true") 301 302 qm := &QueryMeta{} 303 if err := parseQueryMeta(resp, qm); err != nil { 304 t.Fatalf("err: %v", err) 305 } 306 307 if qm.LastIndex != 12345 { 308 t.Fatalf("Bad: %v", qm) 309 } 310 if qm.LastContact != 80*time.Millisecond { 311 t.Fatalf("Bad: %v", qm) 312 } 313 if !qm.KnownLeader { 314 t.Fatalf("Bad: %v", qm) 315 } 316 } 317 318 func TestParseWriteMeta(t *testing.T) { 319 testutil.Parallel(t) 320 resp := &http.Response{ 321 Header: make(map[string][]string), 322 } 323 resp.Header.Set("X-Nomad-Index", "12345") 324 325 wm := &WriteMeta{} 326 if err := parseWriteMeta(resp, wm); err != nil { 327 t.Fatalf("err: %v", err) 328 } 329 330 if wm.LastIndex != 12345 { 331 t.Fatalf("Bad: %v", wm) 332 } 333 } 334 335 func TestClientHeader(t *testing.T) { 336 testutil.Parallel(t) 337 c, s := makeClient(t, func(c *Config) { 338 c.Headers = http.Header{ 339 "Hello": []string{"World"}, 340 } 341 }, nil) 342 defer s.Stop() 343 344 r, _ := c.newRequest("GET", "/v1/jobs") 345 346 if r.header.Get("Hello") != "World" { 347 t.Fatalf("bad: %v", r.header) 348 } 349 } 350 351 func TestQueryString(t *testing.T) { 352 testutil.Parallel(t) 353 c, s := makeClient(t, nil, nil) 354 defer s.Stop() 355 356 r, _ := c.newRequest("PUT", "/v1/abc?foo=bar&baz=zip") 357 q := &WriteOptions{ 358 Region: "foo", 359 Namespace: "bar", 360 } 361 r.setWriteOptions(q) 362 363 req, err := r.toHTTP() 364 if err != nil { 365 t.Fatalf("err: %s", err) 366 } 367 368 if uri := req.URL.RequestURI(); uri != "/v1/abc?baz=zip&foo=bar&namespace=bar®ion=foo" { 369 t.Fatalf("bad uri: %q", uri) 370 } 371 } 372 373 func TestClient_NodeClient(t *testing.T) { 374 addr := "testdomain:4646" 375 tlsNode := func(string, *QueryOptions) (*Node, *QueryMeta, error) { 376 return &Node{ 377 ID: generateUUID(), 378 Status: "ready", 379 HTTPAddr: addr, 380 TLSEnabled: true, 381 }, nil, nil 382 } 383 noTlsNode := func(string, *QueryOptions) (*Node, *QueryMeta, error) { 384 return &Node{ 385 ID: generateUUID(), 386 Status: "ready", 387 HTTPAddr: addr, 388 TLSEnabled: false, 389 }, nil, nil 390 } 391 392 optionNoRegion := &QueryOptions{} 393 optionRegion := &QueryOptions{ 394 Region: "foo", 395 } 396 397 clientNoRegion, err := NewClient(DefaultConfig()) 398 must.NoError(t, err) 399 400 regionConfig := DefaultConfig() 401 regionConfig.Region = "bar" 402 clientRegion, err := NewClient(regionConfig) 403 must.NoError(t, err) 404 405 expectedTLSAddr := fmt.Sprintf("https://%s", addr) 406 expectedNoTLSAddr := fmt.Sprintf("http://%s", addr) 407 408 cases := []struct { 409 Node nodeLookup 410 QueryOptions *QueryOptions 411 Client *Client 412 ExpectedAddr string 413 ExpectedRegion string 414 ExpectedTLSServerName string 415 }{ 416 { 417 Node: tlsNode, 418 QueryOptions: optionNoRegion, 419 Client: clientNoRegion, 420 ExpectedAddr: expectedTLSAddr, 421 ExpectedRegion: "global", 422 ExpectedTLSServerName: "client.global.nomad", 423 }, 424 { 425 Node: tlsNode, 426 QueryOptions: optionRegion, 427 Client: clientNoRegion, 428 ExpectedAddr: expectedTLSAddr, 429 ExpectedRegion: "foo", 430 ExpectedTLSServerName: "client.foo.nomad", 431 }, 432 { 433 Node: tlsNode, 434 QueryOptions: optionRegion, 435 Client: clientRegion, 436 ExpectedAddr: expectedTLSAddr, 437 ExpectedRegion: "foo", 438 ExpectedTLSServerName: "client.foo.nomad", 439 }, 440 { 441 Node: tlsNode, 442 QueryOptions: optionNoRegion, 443 Client: clientRegion, 444 ExpectedAddr: expectedTLSAddr, 445 ExpectedRegion: "bar", 446 ExpectedTLSServerName: "client.bar.nomad", 447 }, 448 { 449 Node: noTlsNode, 450 QueryOptions: optionNoRegion, 451 Client: clientNoRegion, 452 ExpectedAddr: expectedNoTLSAddr, 453 ExpectedRegion: "global", 454 ExpectedTLSServerName: "", 455 }, 456 { 457 Node: noTlsNode, 458 QueryOptions: optionRegion, 459 Client: clientNoRegion, 460 ExpectedAddr: expectedNoTLSAddr, 461 ExpectedRegion: "foo", 462 ExpectedTLSServerName: "", 463 }, 464 { 465 Node: noTlsNode, 466 QueryOptions: optionRegion, 467 Client: clientRegion, 468 ExpectedAddr: expectedNoTLSAddr, 469 ExpectedRegion: "foo", 470 ExpectedTLSServerName: "", 471 }, 472 { 473 Node: noTlsNode, 474 QueryOptions: optionNoRegion, 475 Client: clientRegion, 476 ExpectedAddr: expectedNoTLSAddr, 477 ExpectedRegion: "bar", 478 ExpectedTLSServerName: "", 479 }, 480 } 481 482 for _, c := range cases { 483 name := fmt.Sprintf("%s__%s__%s", c.ExpectedAddr, c.ExpectedRegion, c.ExpectedTLSServerName) 484 t.Run(name, func(t *testing.T) { 485 nodeClient, getErr := c.Client.getNodeClientImpl("testID", -1, c.QueryOptions, c.Node) 486 must.NoError(t, getErr) 487 must.Eq(t, c.ExpectedRegion, nodeClient.config.Region) 488 must.Eq(t, c.ExpectedAddr, nodeClient.config.Address) 489 must.NotNil(t, nodeClient.config.TLSConfig) 490 must.Eq(t, c.ExpectedTLSServerName, nodeClient.config.TLSConfig.TLSServerName) 491 }) 492 } 493 } 494 495 func TestCloneHttpClient(t *testing.T) { 496 client := defaultHttpClient() 497 originalTransport := client.Transport.(*http.Transport) 498 originalTransport.Proxy = func(*http.Request) (*url.URL, error) { 499 return nil, errors.New("stub function") 500 } 501 502 t.Run("closing with negative timeout", func(t *testing.T) { 503 clone, err := cloneWithTimeout(client, -1) 504 must.True(t, originalTransport == client.Transport, must.Sprint("original transport changed")) 505 must.NoError(t, err) 506 must.True(t, client == clone) 507 }) 508 509 t.Run("closing with positive timeout", func(t *testing.T) { 510 clone, err := cloneWithTimeout(client, 1*time.Second) 511 must.True(t, originalTransport == client.Transport, must.Sprint("original transport changed")) 512 must.NoError(t, err) 513 must.True(t, client != clone) 514 must.True(t, client.Transport != clone.Transport) 515 516 // test that proxy function is the same in clone 517 clonedProxy := clone.Transport.(*http.Transport).Proxy 518 must.NotNil(t, clonedProxy) 519 _, err = clonedProxy(nil) 520 must.Error(t, err) 521 must.EqError(t, err, "stub function") 522 523 // if we reset transport, the strutcs are equal 524 clone.Transport = originalTransport 525 must.Eq(t, client, clone) 526 }) 527 528 } 529 530 func TestClient_HeaderRaceCondition(t *testing.T) { 531 conf := DefaultConfig() 532 conf.Headers = map[string][]string{ 533 "test-header": {"a"}, 534 } 535 client, err := NewClient(conf) 536 must.NoError(t, err) 537 538 c := make(chan int) 539 540 go func() { 541 req, _ := client.newRequest("GET", "/any/path/will/do") 542 r, _ := req.toHTTP() 543 c <- len(r.Header) 544 }() 545 req, _ := client.newRequest("GET", "/any/path/will/do") 546 r, _ := req.toHTTP() 547 548 must.MapLen(t, 2, r.Header, must.Sprint("local request should have two headers")) 549 must.Eq(t, 2, <-c, must.Sprint("goroutine request should have two headers")) 550 must.MapLen(t, 1, conf.Headers, must.Sprint("config headers should not mutate")) 551 } 552 553 func TestClient_autoUnzip(t *testing.T) { 554 var client *Client = nil 555 556 try := func(resp *http.Response, exp error) { 557 err := client.autoUnzip(resp) 558 must.Eq(t, exp, err) 559 } 560 561 // response object is nil 562 try(nil, nil) 563 564 // response.Body is nil 565 try(new(http.Response), nil) 566 567 // content-encoding is not gzip 568 try(&http.Response{ 569 Header: http.Header{"Content-Encoding": []string{"text"}}, 570 }, nil) 571 572 // content-encoding is gzip but body is empty 573 try(&http.Response{ 574 Header: http.Header{"Content-Encoding": []string{"gzip"}}, 575 Body: io.NopCloser(bytes.NewBuffer([]byte{})), 576 }, nil) 577 578 // content-encoding is gzip but body is invalid gzip 579 try(&http.Response{ 580 Header: http.Header{"Content-Encoding": []string{"gzip"}}, 581 Body: io.NopCloser(bytes.NewBuffer([]byte("not a zip"))), 582 }, errors.New("unexpected EOF")) 583 584 // sample gzip payload 585 var b bytes.Buffer 586 w := gzip.NewWriter(&b) 587 _, err := w.Write([]byte("hello world")) 588 must.NoError(t, err) 589 err = w.Close() 590 must.NoError(t, err) 591 592 // content-encoding is gzip and body is gzip data 593 try(&http.Response{ 594 Header: http.Header{"Content-Encoding": []string{"gzip"}}, 595 Body: io.NopCloser(&b), 596 }, nil) 597 }