github.com/hashicorp/nomad/api@v0.0.0-20240306165712-3193ac204f65/internal/testutil/server.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package testutil
     5  
     6  // TestServer is a test helper. It uses a fork/exec model to create
     7  // a test Nomad server instance in the background and initialize it
     8  // with some data and/or services. The test server can then be used
     9  // to run a unit test, and offers an easy API to tear itself down
    10  // when the test has completed. The only prerequisite is to have a nomad
    11  // binary available on the $PATH.
    12  //
    13  // This package does not use Nomad's official API client. This is
    14  // because we use TestServer to test the API client, which would
    15  // otherwise cause an import cycle.
    16  
    17  import (
    18  	"bytes"
    19  	"encoding/json"
    20  	"fmt"
    21  	"io"
    22  	"net/http"
    23  	"os"
    24  	"os/exec"
    25  	"time"
    26  
    27  	"github.com/hashicorp/go-cleanhttp"
    28  	"github.com/hashicorp/nomad/api/internal/testutil/discover"
    29  	testing "github.com/mitchellh/go-testing-interface"
    30  	"github.com/shoenig/test/must"
    31  	"github.com/shoenig/test/wait"
    32  )
    33  
    34  // TestServerConfig is the main server configuration struct.
    35  type TestServerConfig struct {
    36  	NodeName          string        `json:"name,omitempty"`
    37  	DataDir           string        `json:"data_dir,omitempty"`
    38  	Region            string        `json:"region,omitempty"`
    39  	DisableCheckpoint bool          `json:"disable_update_check"`
    40  	LogLevel          string        `json:"log_level,omitempty"`
    41  	Consul            *Consul       `json:"consul,omitempty"`
    42  	AdvertiseAddrs    *Advertise    `json:"advertise,omitempty"`
    43  	Ports             *PortsConfig  `json:"ports,omitempty"`
    44  	Server            *ServerConfig `json:"server,omitempty"`
    45  	Client            *ClientConfig `json:"client,omitempty"`
    46  	Vault             *VaultConfig  `json:"vault,omitempty"`
    47  	ACL               *ACLConfig    `json:"acl,omitempty"`
    48  	Telemetry         *Telemetry    `json:"telemetry,omitempty"`
    49  	DevMode           bool          `json:"-"`
    50  	Stdout, Stderr    io.Writer     `json:"-"`
    51  }
    52  
    53  // Consul is used to configure the communication with Consul
    54  type Consul struct {
    55  	Address string `json:"address,omitempty"`
    56  	Auth    string `json:"auth,omitempty"`
    57  	Token   string `json:"token,omitempty"`
    58  }
    59  
    60  // Advertise is used to configure the addresses to advertise
    61  type Advertise struct {
    62  	HTTP string `json:"http,omitempty"`
    63  	RPC  string `json:"rpc,omitempty"`
    64  	Serf string `json:"serf,omitempty"`
    65  }
    66  
    67  // PortsConfig is used to configure the network ports we use.
    68  type PortsConfig struct {
    69  	HTTP int `json:"http,omitempty"`
    70  	RPC  int `json:"rpc,omitempty"`
    71  	Serf int `json:"serf,omitempty"`
    72  }
    73  
    74  // ServerConfig is used to configure the nomad server.
    75  type ServerConfig struct {
    76  	Enabled         bool `json:"enabled"`
    77  	BootstrapExpect int  `json:"bootstrap_expect"`
    78  	RaftProtocol    int  `json:"raft_protocol,omitempty"`
    79  }
    80  
    81  // ClientConfig is used to configure the client
    82  type ClientConfig struct {
    83  	Enabled bool              `json:"enabled"`
    84  	Options map[string]string `json:"options,omitempty"`
    85  }
    86  
    87  // VaultConfig is used to configure Vault
    88  type VaultConfig struct {
    89  	Enabled bool `json:"enabled"`
    90  }
    91  
    92  // ACLConfig is used to configure ACLs
    93  type ACLConfig struct {
    94  	Enabled bool `json:"enabled"`
    95  }
    96  
    97  // Telemetry is used to configure the Nomad telemetry setup.
    98  type Telemetry struct {
    99  	PrometheusMetrics bool `json:"prometheus_metrics"`
   100  }
   101  
   102  // ServerConfigCallback is a function interface which can be
   103  // passed to NewTestServerConfig to modify the server config.
   104  type ServerConfigCallback func(c *TestServerConfig)
   105  
   106  // defaultServerConfig returns a new TestServerConfig struct pre-populated with
   107  // usable config for running as server.
   108  func defaultServerConfig(t testing.T) *TestServerConfig {
   109  	ports := PortAllocator.Grab(3)
   110  
   111  	logLevel := "ERROR"
   112  	if envLogLevel := os.Getenv("NOMAD_TEST_LOG_LEVEL"); envLogLevel != "" {
   113  		logLevel = envLogLevel
   114  	}
   115  
   116  	return &TestServerConfig{
   117  		NodeName:          fmt.Sprintf("node-%d", ports[0]),
   118  		DisableCheckpoint: true,
   119  		LogLevel:          logLevel,
   120  		Ports: &PortsConfig{
   121  			HTTP: ports[0],
   122  			RPC:  ports[1],
   123  			Serf: ports[2],
   124  		},
   125  		Server: &ServerConfig{
   126  			Enabled:         true,
   127  			BootstrapExpect: 1,
   128  		},
   129  		Client: &ClientConfig{
   130  			Enabled: false,
   131  		},
   132  		Vault: &VaultConfig{
   133  			Enabled: false,
   134  		},
   135  		ACL: &ACLConfig{
   136  			Enabled: false,
   137  		},
   138  	}
   139  }
   140  
   141  // TestServer is the main server wrapper struct.
   142  type TestServer struct {
   143  	cmd    *exec.Cmd
   144  	Config *TestServerConfig
   145  	t      testing.T
   146  
   147  	HTTPAddr   string
   148  	SerfAddr   string
   149  	HTTPClient *http.Client
   150  }
   151  
   152  // NewTestServer creates a new TestServer, and makes a call to
   153  // an optional callback function to modify the configuration.
   154  func NewTestServer(t testing.T, cb ServerConfigCallback) *TestServer {
   155  	path, err := discover.NomadExecutable()
   156  	if err != nil {
   157  		t.Skipf("nomad not found, skipping: %v", err)
   158  	}
   159  
   160  	// Check that we are actually running nomad
   161  	_, err = exec.Command(path, "-version").CombinedOutput()
   162  	must.NoError(t, err)
   163  
   164  	dataDir, err := os.MkdirTemp("", "nomad")
   165  	must.NoError(t, err)
   166  
   167  	configFile, err := os.CreateTemp(dataDir, "nomad")
   168  	must.NoError(t, err)
   169  
   170  	nomadConfig := defaultServerConfig(t)
   171  	nomadConfig.DataDir = dataDir
   172  
   173  	if cb != nil {
   174  		cb(nomadConfig)
   175  	}
   176  
   177  	if nomadConfig.DevMode {
   178  		if nomadConfig.Client.Options == nil {
   179  			nomadConfig.Client.Options = map[string]string{}
   180  		}
   181  		nomadConfig.Client.Options["test.tighten_network_timeouts"] = "true"
   182  	}
   183  
   184  	configContent, err := json.Marshal(nomadConfig)
   185  	must.NoError(t, err)
   186  
   187  	_, err = configFile.Write(configContent)
   188  	must.NoError(t, err)
   189  	must.NoError(t, configFile.Sync())
   190  	must.NoError(t, configFile.Close())
   191  
   192  	args := []string{"agent", "-config", configFile.Name()}
   193  	if nomadConfig.DevMode {
   194  		args = append(args, "-dev")
   195  	}
   196  
   197  	stdout := io.Writer(os.Stdout)
   198  	if nomadConfig.Stdout != nil {
   199  		stdout = nomadConfig.Stdout
   200  	}
   201  
   202  	stderr := io.Writer(os.Stderr)
   203  	if nomadConfig.Stderr != nil {
   204  		stderr = nomadConfig.Stderr
   205  	}
   206  
   207  	// Start the server
   208  	cmd := exec.Command(path, args...)
   209  	cmd.Stdout = stdout
   210  	cmd.Stderr = stderr
   211  	must.NoError(t, cmd.Start())
   212  
   213  	client := cleanhttp.DefaultClient()
   214  	client.Timeout = 10 * time.Second
   215  
   216  	server := &TestServer{
   217  		Config: nomadConfig,
   218  		cmd:    cmd,
   219  		t:      t,
   220  
   221  		HTTPAddr:   fmt.Sprintf("127.0.0.1:%d", nomadConfig.Ports.HTTP),
   222  		SerfAddr:   fmt.Sprintf("127.0.0.1:%d", nomadConfig.Ports.Serf),
   223  		HTTPClient: client,
   224  	}
   225  
   226  	// Wait for the server to be ready
   227  	if nomadConfig.Server.Enabled && nomadConfig.Server.BootstrapExpect != 0 {
   228  		server.waitForServers()
   229  	} else {
   230  		server.waitForAPI()
   231  	}
   232  
   233  	// Wait for the client to be ready
   234  	if nomadConfig.DevMode {
   235  		server.waitForClient()
   236  	}
   237  	return server
   238  }
   239  
   240  // Stop stops the test Nomad server, and removes the Nomad data
   241  // directory once we are done.
   242  func (s *TestServer) Stop() {
   243  	defer func() { _ = os.RemoveAll(s.Config.DataDir) }()
   244  
   245  	// wait for the process to exit to be sure that the data dir can be
   246  	// deleted on all platforms.
   247  	done := make(chan struct{})
   248  	go func() {
   249  		defer close(done)
   250  		_ = s.cmd.Wait()
   251  	}()
   252  
   253  	// kill and wait gracefully
   254  	err := s.cmd.Process.Signal(os.Interrupt)
   255  	must.NoError(s.t, err)
   256  
   257  	select {
   258  	case <-done:
   259  		return
   260  	case <-time.After(5 * time.Second):
   261  		s.t.Logf("timed out waiting for process to gracefully terminate")
   262  	}
   263  
   264  	err = s.cmd.Process.Kill()
   265  	must.NoError(s.t, err, must.Sprint("failed to kill process"))
   266  
   267  	select {
   268  	case <-done:
   269  	case <-time.After(5 * time.Second):
   270  		s.t.Logf("timed out waiting for process to be killed")
   271  	}
   272  }
   273  
   274  // waitForAPI waits for only the agent HTTP endpoint to start
   275  // responding. This is an indication that the agent has started,
   276  // but will likely return before a leader is elected.
   277  func (s *TestServer) waitForAPI() {
   278  	f := func() error {
   279  		resp, err := s.HTTPClient.Get(s.url("/v1/metrics"))
   280  		if err != nil {
   281  			return fmt.Errorf("failed to get metrics: %w", err)
   282  		}
   283  		defer func() { _ = resp.Body.Close() }()
   284  		if err = s.requireOK(resp); err != nil {
   285  			return fmt.Errorf("metrics response is not ok: %w", err)
   286  		}
   287  		return nil
   288  	}
   289  	must.Wait(s.t,
   290  		wait.InitialSuccess(
   291  			wait.ErrorFunc(f),
   292  			wait.Timeout(10*time.Second),
   293  			wait.Gap(1*time.Second),
   294  		),
   295  		must.Sprint("failed to wait for api"),
   296  	)
   297  }
   298  
   299  // waitForServers waits for the Nomad server's HTTP API to become available,
   300  // and then waits for the keyring to be intialized. This implies a leader has
   301  // been elected and Raft writes have occurred.
   302  func (s *TestServer) waitForServers() {
   303  	f := func() error {
   304  		resp, err := s.HTTPClient.Get(s.url("/.well-known/jwks.json"))
   305  		if err != nil {
   306  			return fmt.Errorf("failed to contact leader: %w", err)
   307  		}
   308  		defer func() { _ = resp.Body.Close() }()
   309  		if err = s.requireOK(resp); err != nil {
   310  			return fmt.Errorf("leader response is not ok: %w", err)
   311  		}
   312  
   313  		jwks := struct {
   314  			Keys []interface{} `json:"keys"`
   315  		}{}
   316  		if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
   317  			return fmt.Errorf("error decoding jwks response: %w", err)
   318  		}
   319  		if len(jwks.Keys) == 0 {
   320  			return fmt.Errorf("no keys found")
   321  		}
   322  		return nil
   323  	}
   324  	must.Wait(s.t,
   325  		wait.InitialSuccess(
   326  			wait.ErrorFunc(f),
   327  			wait.Timeout(10*time.Second),
   328  			wait.Gap(1*time.Second),
   329  		),
   330  		must.Sprint("failed to wait for leader"),
   331  	)
   332  }
   333  
   334  // waitForClient waits for the Nomad client to be ready. The function returns
   335  // immediately if the server is not in dev mode.
   336  func (s *TestServer) waitForClient() {
   337  	if !s.Config.DevMode {
   338  		return
   339  	}
   340  	f := func() error {
   341  		resp, err := s.HTTPClient.Get(s.url("/v1/nodes"))
   342  		if err != nil {
   343  			return fmt.Errorf("failed to get nodes: %w", err)
   344  		}
   345  		defer func() { _ = resp.Body.Close() }()
   346  		if err = s.requireOK(resp); err != nil {
   347  			return fmt.Errorf("nodes response not ok: %w", err)
   348  		}
   349  		var decoded []struct {
   350  			ID     string
   351  			Status string
   352  		}
   353  		if err = json.NewDecoder(resp.Body).Decode(&decoded); err != nil {
   354  			return fmt.Errorf("failed to decode nodes response: %w", err)
   355  		}
   356  		return nil
   357  	}
   358  	must.Wait(s.t,
   359  		wait.InitialSuccess(
   360  			wait.ErrorFunc(f),
   361  			wait.Timeout(10*time.Second),
   362  			wait.Gap(1*time.Second),
   363  		),
   364  		must.Sprint("failed to wait for client (node)"),
   365  	)
   366  }
   367  
   368  // url is a helper function which takes a relative URL and
   369  // makes it into a proper URL against the local Nomad server.
   370  func (s *TestServer) url(path string) string {
   371  	return fmt.Sprintf("http://%s%s", s.HTTPAddr, path)
   372  }
   373  
   374  // requireOK checks the HTTP response code and ensures it is acceptable.
   375  func (s *TestServer) requireOK(resp *http.Response) error {
   376  	if resp.StatusCode != http.StatusOK {
   377  		return fmt.Errorf("bad status code: %d", resp.StatusCode)
   378  	}
   379  	return nil
   380  }
   381  
   382  // put performs a new HTTP PUT request.
   383  func (s *TestServer) put(path string, body io.Reader) *http.Response {
   384  	req, err := http.NewRequest(http.MethodPut, s.url(path), body)
   385  	must.NoError(s.t, err)
   386  
   387  	resp, err := s.HTTPClient.Do(req)
   388  	must.NoError(s.t, err)
   389  
   390  	if err = s.requireOK(resp); err != nil {
   391  		_ = resp.Body.Close()
   392  		must.NoError(s.t, err)
   393  	}
   394  	return resp
   395  }
   396  
   397  // get performs a new HTTP GET request.
   398  func (s *TestServer) get(path string) *http.Response {
   399  	resp, err := s.HTTPClient.Get(s.url(path))
   400  	must.NoError(s.t, err)
   401  
   402  	if err = s.requireOK(resp); err != nil {
   403  		_ = resp.Body.Close()
   404  		must.NoError(s.t, err)
   405  	}
   406  	return resp
   407  }
   408  
   409  // encodePayload returns a new io.Reader wrapping the encoded contents
   410  // of the payload, suitable for passing directly to a new request.
   411  func (s *TestServer) encodePayload(payload any) io.Reader {
   412  	var encoded bytes.Buffer
   413  	err := json.NewEncoder(&encoded).Encode(payload)
   414  	must.NoError(s.t, err)
   415  	return &encoded
   416  }