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&region=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&region=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  }