github.com/mattyr/nomad@v0.3.3-0.20160919021406-3485a065154a/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 "sync/atomic" 24 "testing" 25 26 "github.com/hashicorp/go-cleanhttp" 27 ) 28 29 // offset is used to atomically increment the port numbers. 30 var offset uint64 31 32 // TestServerConfig is the main server configuration struct. 33 type TestServerConfig struct { 34 NodeName string `json:"name,omitempty"` 35 DataDir string `json:"data_dir,omitempty"` 36 Region string `json:"region,omitempty"` 37 DisableCheckpoint bool `json:"disable_update_check"` 38 LogLevel string `json:"log_level,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 DevMode bool `json:"-"` 44 Stdout, Stderr io.Writer `json:"-"` 45 } 46 47 // PortsConfig is used to configure the network ports we use. 48 type PortsConfig struct { 49 HTTP int `json:"http,omitempty"` 50 RPC int `json:"rpc,omitempty"` 51 Serf int `json:"serf,omitempty"` 52 } 53 54 // ServerConfig is used to configure the nomad server. 55 type ServerConfig struct { 56 Enabled bool `json:"enabled"` 57 BootstrapExpect int `json:"bootstrap_expect"` 58 } 59 60 // ClientConfig is used to configure the client 61 type ClientConfig struct { 62 Enabled bool `json:"enabled"` 63 } 64 65 // VaultConfig is used to configure Vault 66 type VaultConfig struct { 67 Enabled bool `json:"enabled"` 68 } 69 70 // ServerConfigCallback is a function interface which can be 71 // passed to NewTestServerConfig to modify the server config. 72 type ServerConfigCallback func(c *TestServerConfig) 73 74 // defaultServerConfig returns a new TestServerConfig struct 75 // with all of the listen ports incremented by one. 76 func defaultServerConfig() *TestServerConfig { 77 idx := int(atomic.AddUint64(&offset, 1)) 78 79 return &TestServerConfig{ 80 NodeName: fmt.Sprintf("node%d", idx), 81 DisableCheckpoint: true, 82 LogLevel: "DEBUG", 83 Ports: &PortsConfig{ 84 HTTP: 20000 + idx, 85 RPC: 21000 + idx, 86 Serf: 22000 + idx, 87 }, 88 Server: &ServerConfig{ 89 Enabled: true, 90 BootstrapExpect: 1, 91 }, 92 Client: &ClientConfig{ 93 Enabled: false, 94 }, 95 Vault: &VaultConfig{ 96 Enabled: false, 97 }, 98 } 99 } 100 101 // TestServer is the main server wrapper struct. 102 type TestServer struct { 103 cmd *exec.Cmd 104 Config *TestServerConfig 105 t *testing.T 106 107 HTTPAddr string 108 SerfAddr string 109 HTTPClient *http.Client 110 } 111 112 // NewTestServer creates a new TestServer, and makes a call to 113 // an optional callback function to modify the configuration. 114 func NewTestServer(t *testing.T, cb ServerConfigCallback) *TestServer { 115 if path, err := exec.LookPath("nomad"); err != nil || path == "" { 116 t.Skip("nomad not found on $PATH, skipping") 117 } 118 119 dataDir, err := ioutil.TempDir("", "nomad") 120 if err != nil { 121 t.Fatalf("err: %s", err) 122 } 123 124 configFile, err := ioutil.TempFile(dataDir, "nomad") 125 if err != nil { 126 defer os.RemoveAll(dataDir) 127 t.Fatalf("err: %s", err) 128 } 129 defer configFile.Close() 130 131 nomadConfig := defaultServerConfig() 132 nomadConfig.DataDir = dataDir 133 134 if cb != nil { 135 cb(nomadConfig) 136 } 137 138 configContent, err := json.Marshal(nomadConfig) 139 if err != nil { 140 t.Fatalf("err: %s", err) 141 } 142 143 if _, err := configFile.Write(configContent); err != nil { 144 t.Fatalf("err: %s", err) 145 } 146 configFile.Close() 147 148 stdout := io.Writer(os.Stdout) 149 if nomadConfig.Stdout != nil { 150 stdout = nomadConfig.Stdout 151 } 152 153 stderr := io.Writer(os.Stderr) 154 if nomadConfig.Stderr != nil { 155 stderr = nomadConfig.Stderr 156 } 157 158 args := []string{"agent", "-config", configFile.Name()} 159 if nomadConfig.DevMode { 160 args = append(args, "-dev") 161 } 162 163 // Start the server 164 cmd := exec.Command("nomad", args...) 165 cmd.Stdout = stdout 166 cmd.Stderr = stderr 167 if err := cmd.Start(); err != nil { 168 t.Fatalf("err: %s", err) 169 } 170 171 client := cleanhttp.DefaultClient() 172 173 server := &TestServer{ 174 Config: nomadConfig, 175 cmd: cmd, 176 t: t, 177 178 HTTPAddr: fmt.Sprintf("127.0.0.1:%d", nomadConfig.Ports.HTTP), 179 SerfAddr: fmt.Sprintf("127.0.0.1:%d", nomadConfig.Ports.Serf), 180 HTTPClient: client, 181 } 182 183 // Wait for the server to be ready 184 if nomadConfig.Server.Enabled && nomadConfig.Server.BootstrapExpect != 0 { 185 server.waitForLeader() 186 } else { 187 server.waitForAPI() 188 } 189 190 // Wait for the client to be ready 191 if nomadConfig.DevMode { 192 server.waitForClient() 193 } 194 return server 195 } 196 197 // Stop stops the test Nomad server, and removes the Nomad data 198 // directory once we are done. 199 func (s *TestServer) Stop() { 200 defer os.RemoveAll(s.Config.DataDir) 201 202 if err := s.cmd.Process.Kill(); err != nil { 203 s.t.Errorf("err: %s", err) 204 } 205 206 // wait for the process to exit to be sure that the data dir can be 207 // deleted on all platforms. 208 s.cmd.Wait() 209 } 210 211 // waitForAPI waits for only the agent HTTP endpoint to start 212 // responding. This is an indication that the agent has started, 213 // but will likely return before a leader is elected. 214 func (s *TestServer) waitForAPI() { 215 WaitForResult(func() (bool, error) { 216 resp, err := s.HTTPClient.Get(s.url("/v1/agent/self")) 217 if err != nil { 218 return false, err 219 } 220 defer resp.Body.Close() 221 if err := s.requireOK(resp); err != nil { 222 return false, err 223 } 224 return true, nil 225 }, func(err error) { 226 defer s.Stop() 227 s.t.Fatalf("err: %s", err) 228 }) 229 } 230 231 // waitForLeader waits for the Nomad server's HTTP API to become 232 // available, and then waits for a known leader and an index of 233 // 1 or more to be observed to confirm leader election is done. 234 func (s *TestServer) waitForLeader() { 235 WaitForResult(func() (bool, error) { 236 // Query the API and check the status code 237 resp, err := s.HTTPClient.Get(s.url("/v1/jobs")) 238 if err != nil { 239 return false, err 240 } 241 defer resp.Body.Close() 242 if err := s.requireOK(resp); err != nil { 243 return false, err 244 } 245 246 // Ensure we have a leader and a node registeration 247 if leader := resp.Header.Get("X-Nomad-KnownLeader"); leader != "true" { 248 return false, fmt.Errorf("Nomad leader status: %#v", leader) 249 } 250 return true, nil 251 }, func(err error) { 252 defer s.Stop() 253 s.t.Fatalf("err: %s", err) 254 }) 255 } 256 257 // waitForClient waits for the Nomad client to be ready. The function returns 258 // immediately if the server is not in dev mode. 259 func (s *TestServer) waitForClient() { 260 if !s.Config.DevMode { 261 return 262 } 263 264 WaitForResult(func() (bool, error) { 265 resp, err := s.HTTPClient.Get(s.url("/v1/nodes")) 266 if err != nil { 267 return false, err 268 } 269 defer resp.Body.Close() 270 if err := s.requireOK(resp); err != nil { 271 return false, err 272 } 273 274 var decoded []struct { 275 ID string 276 Status string 277 } 278 279 dec := json.NewDecoder(resp.Body) 280 if err := dec.Decode(&decoded); err != nil { 281 return false, err 282 } 283 284 if len(decoded) != 1 || decoded[0].Status != "ready" { 285 return false, fmt.Errorf("Node not ready: %v", decoded) 286 } 287 288 return true, nil 289 }, func(err error) { 290 defer s.Stop() 291 s.t.Fatalf("err: %s", err) 292 }) 293 } 294 295 // url is a helper function which takes a relative URL and 296 // makes it into a proper URL against the local Nomad server. 297 func (s *TestServer) url(path string) string { 298 return fmt.Sprintf("http://%s%s", s.HTTPAddr, path) 299 } 300 301 // requireOK checks the HTTP response code and ensures it is acceptable. 302 func (s *TestServer) requireOK(resp *http.Response) error { 303 if resp.StatusCode != 200 { 304 return fmt.Errorf("Bad status code: %d", resp.StatusCode) 305 } 306 return nil 307 } 308 309 // put performs a new HTTP PUT request. 310 func (s *TestServer) put(path string, body io.Reader) *http.Response { 311 req, err := http.NewRequest("PUT", s.url(path), body) 312 if err != nil { 313 s.t.Fatalf("err: %s", err) 314 } 315 resp, err := s.HTTPClient.Do(req) 316 if err != nil { 317 s.t.Fatalf("err: %s", err) 318 } 319 if err := s.requireOK(resp); err != nil { 320 defer resp.Body.Close() 321 s.t.Fatal(err) 322 } 323 return resp 324 } 325 326 // get performs a new HTTP GET request. 327 func (s *TestServer) get(path string) *http.Response { 328 resp, err := s.HTTPClient.Get(s.url(path)) 329 if err != nil { 330 s.t.Fatalf("err: %s", err) 331 } 332 if err := s.requireOK(resp); err != nil { 333 defer resp.Body.Close() 334 s.t.Fatal(err) 335 } 336 return resp 337 } 338 339 // encodePayload returns a new io.Reader wrapping the encoded contents 340 // of the payload, suitable for passing directly to a new request. 341 func (s *TestServer) encodePayload(payload interface{}) io.Reader { 342 var encoded bytes.Buffer 343 enc := json.NewEncoder(&encoded) 344 if err := enc.Encode(payload); err != nil { 345 s.t.Fatalf("err: %s", err) 346 } 347 return &encoded 348 }