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