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 }