github.com/Ilhicas/nomad@v1.0.4-0.20210304152020-e86851182bc3/client/allocrunner/taskrunner/envoy_bootstrap_hook.go (about) 1 package taskrunner 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "io/ioutil" 8 "os" 9 "os/exec" 10 "path/filepath" 11 "time" 12 13 "github.com/hashicorp/go-hclog" 14 "github.com/hashicorp/nomad/client/allocdir" 15 ifs "github.com/hashicorp/nomad/client/allocrunner/interfaces" 16 agentconsul "github.com/hashicorp/nomad/command/agent/consul" 17 "github.com/hashicorp/nomad/helper" 18 "github.com/hashicorp/nomad/nomad/structs" 19 "github.com/hashicorp/nomad/nomad/structs/config" 20 "github.com/pkg/errors" 21 ) 22 23 const envoyBootstrapHookName = "envoy_bootstrap" 24 25 type consulTransportConfig struct { 26 HTTPAddr string // required 27 Auth string // optional, env CONSUL_HTTP_AUTH 28 SSL string // optional, env CONSUL_HTTP_SSL 29 VerifySSL string // optional, env CONSUL_HTTP_SSL_VERIFY 30 CAFile string // optional, arg -ca-file 31 CertFile string // optional, arg -client-cert 32 KeyFile string // optional, arg -client-key 33 Namespace string // optional, only consul Enterprise, env CONSUL_NAMESPACE 34 // CAPath (dir) not supported by Nomad's config object 35 } 36 37 func newConsulTransportConfig(consul *config.ConsulConfig) consulTransportConfig { 38 return consulTransportConfig{ 39 HTTPAddr: consul.Addr, 40 Auth: consul.Auth, 41 SSL: decodeTriState(consul.EnableSSL), 42 VerifySSL: decodeTriState(consul.VerifySSL), 43 CAFile: consul.CAFile, 44 CertFile: consul.CertFile, 45 KeyFile: consul.KeyFile, 46 Namespace: consul.Namespace, 47 } 48 } 49 50 type envoyBootstrapHookConfig struct { 51 consul consulTransportConfig 52 alloc *structs.Allocation 53 logger hclog.Logger 54 } 55 56 func decodeTriState(b *bool) string { 57 switch { 58 case b == nil: 59 return "" 60 case *b: 61 return "true" 62 default: 63 return "false" 64 } 65 } 66 67 func newEnvoyBootstrapHookConfig(alloc *structs.Allocation, consul *config.ConsulConfig, logger hclog.Logger) *envoyBootstrapHookConfig { 68 return &envoyBootstrapHookConfig{ 69 alloc: alloc, 70 logger: logger, 71 consul: newConsulTransportConfig(consul), 72 } 73 } 74 75 const ( 76 envoyBaseAdminPort = 19000 77 envoyAdminBindEnvPrefix = "NOMAD_ENVOY_ADMIN_ADDR_" 78 ) 79 80 const ( 81 grpcConsulVariable = "CONSUL_GRPC_ADDR" 82 grpcDefaultAddress = "127.0.0.1:8502" 83 ) 84 85 // envoyBootstrapHook writes the bootstrap config for the Connect Envoy proxy 86 // sidecar. 87 type envoyBootstrapHook struct { 88 // alloc is the allocation with the envoy task being bootstrapped. 89 alloc *structs.Allocation 90 91 // Bootstrapping Envoy requires talking directly to Consul to generate 92 // the bootstrap.json config. Runtime Envoy configuration is done via 93 // Consul's gRPC endpoint. There are many security parameters to configure 94 // before contacting Consul. 95 consulConfig consulTransportConfig 96 97 // logger is used to log things 98 logger hclog.Logger 99 } 100 101 func newEnvoyBootstrapHook(c *envoyBootstrapHookConfig) *envoyBootstrapHook { 102 return &envoyBootstrapHook{ 103 alloc: c.alloc, 104 consulConfig: c.consul, 105 logger: c.logger.Named(envoyBootstrapHookName), 106 } 107 } 108 109 func (envoyBootstrapHook) Name() string { 110 return envoyBootstrapHookName 111 } 112 113 func isConnectKind(kind string) bool { 114 kinds := []string{structs.ConnectProxyPrefix, structs.ConnectIngressPrefix, structs.ConnectTerminatingPrefix} 115 return helper.SliceStringContains(kinds, kind) 116 } 117 118 func (_ *envoyBootstrapHook) extractNameAndKind(kind structs.TaskKind) (string, string, error) { 119 serviceName := kind.Value() 120 serviceKind := kind.Name() 121 122 if !isConnectKind(serviceKind) { 123 return "", "", errors.New("envoy must be used as connect sidecar or gateway") 124 } 125 126 if serviceName == "" { 127 return "", "", errors.New("envoy must be configured with a service name") 128 } 129 130 return serviceKind, serviceName, nil 131 } 132 133 func (h *envoyBootstrapHook) lookupService(svcKind, svcName, tgName string) (*structs.Service, error) { 134 tg := h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup) 135 136 var service *structs.Service 137 for _, s := range tg.Services { 138 if s.Name == svcName { 139 service = s 140 break 141 } 142 } 143 144 if service == nil { 145 if svcKind == structs.ConnectProxyPrefix { 146 return nil, errors.New("connect proxy sidecar task exists but no services configured with a sidecar") 147 } else { 148 return nil, errors.New("connect gateway task exists but no service associated") 149 } 150 } 151 152 return service, nil 153 } 154 155 // Prestart creates an envoy bootstrap config file. 156 // 157 // Must be aware of both launching envoy as a sidecar proxy, as well as a connect gateway. 158 func (h *envoyBootstrapHook) Prestart(ctx context.Context, req *ifs.TaskPrestartRequest, resp *ifs.TaskPrestartResponse) error { 159 if !req.Task.Kind.IsConnectProxy() && !req.Task.Kind.IsAnyConnectGateway() { 160 // Not a Connect proxy sidecar 161 resp.Done = true 162 return nil 163 } 164 165 serviceKind, serviceName, err := h.extractNameAndKind(req.Task.Kind) 166 if err != nil { 167 return err 168 } 169 170 service, err := h.lookupService(serviceKind, serviceName, h.alloc.TaskGroup) 171 if err != nil { 172 return err 173 } 174 175 grpcAddr := h.grpcAddress(req.TaskEnv.EnvMap) 176 177 h.logger.Debug("bootstrapping Consul "+serviceKind, "task", req.Task.Name, "service", serviceName) 178 179 // Envoy runs an administrative API on the loopback interface. There is no 180 // way to turn this feature off. 181 // https://github.com/envoyproxy/envoy/issues/1297 182 envoyAdminBind := buildEnvoyAdminBind(h.alloc, serviceName, req.Task.Name) 183 resp.Env = map[string]string{ 184 helper.CleanEnvVar(envoyAdminBindEnvPrefix+serviceName, '_'): envoyAdminBind, 185 } 186 187 // Envoy bootstrap configuration may contain a Consul token, so write 188 // it to the secrets directory like Vault tokens. 189 bootstrapFilePath := filepath.Join(req.TaskDir.SecretsDir, "envoy_bootstrap.json") 190 191 siToken, err := h.maybeLoadSIToken(req.Task.Name, req.TaskDir.SecretsDir) 192 if err != nil { 193 h.logger.Error("failed to generate envoy bootstrap config", "sidecar_for", service.Name) 194 return errors.Wrap(err, "failed to generate envoy bootstrap config") 195 } 196 h.logger.Debug("check for SI token for task", "task", req.Task.Name, "exists", siToken != "") 197 198 bootstrap := h.newEnvoyBootstrapArgs(h.alloc.TaskGroup, service, grpcAddr, envoyAdminBind, siToken, bootstrapFilePath) 199 bootstrapArgs := bootstrap.args() 200 bootstrapEnv := bootstrap.env(os.Environ()) 201 202 // Since Consul services are registered asynchronously with this task 203 // hook running, retry a small number of times with backoff. 204 for tries := 3; ; tries-- { 205 206 cmd := exec.CommandContext(ctx, "consul", bootstrapArgs...) 207 cmd.Env = bootstrapEnv 208 209 // Redirect output to secrets/envoy_bootstrap.json 210 fd, err := os.Create(bootstrapFilePath) 211 if err != nil { 212 return fmt.Errorf("error creating secrets/envoy_bootstrap.json for envoy: %v", err) 213 } 214 cmd.Stdout = fd 215 216 buf := bytes.NewBuffer(nil) 217 cmd.Stderr = buf 218 219 // Generate bootstrap 220 err = cmd.Run() 221 222 // Close bootstrap.json 223 fd.Close() 224 225 if err == nil { 226 // Happy path! Bootstrap was created, exit. 227 break 228 } 229 230 // Check for error from command 231 if tries == 0 { 232 h.logger.Error("error creating bootstrap configuration for Connect proxy sidecar", "error", err, "stderr", buf.String()) 233 234 // Cleanup the bootstrap file. An errors here is not 235 // important as (a) we test to ensure the deletion 236 // occurs, and (b) the file will either be rewritten on 237 // retry or eventually garbage collected if the task 238 // fails. 239 os.Remove(bootstrapFilePath) 240 241 // ExitErrors are recoverable since they indicate the 242 // command was runnable but exited with a unsuccessful 243 // error code. 244 _, recoverable := err.(*exec.ExitError) 245 return structs.NewRecoverableError( 246 fmt.Errorf("error creating bootstrap configuration for Connect proxy sidecar: %v", err), 247 recoverable, 248 ) 249 } 250 251 // Sleep before retrying to give Consul services time to register 252 select { 253 case <-time.After(2 * time.Second): 254 case <-ctx.Done(): 255 // Killed before bootstrap, exit without setting Done 256 return nil 257 } 258 } 259 260 // Bootstrap written. Mark as done and move on. 261 resp.Done = true 262 return nil 263 } 264 265 // buildEnvoyAdminBind determines a unique port for use by the envoy admin 266 // listener. 267 // 268 // In bridge mode, if multiple sidecars are running, the bind addresses need 269 // to be unique within the namespace, so we simply start at 19000 and increment 270 // by the index of the task. 271 // 272 // In host mode, use the port provided through the service definition, which can 273 // be a port chosen by Nomad. 274 func buildEnvoyAdminBind(alloc *structs.Allocation, serviceName, taskName string) string { 275 tg := alloc.Job.LookupTaskGroup(alloc.TaskGroup) 276 port := envoyBaseAdminPort 277 switch tg.Networks[0].Mode { 278 case "host": 279 for _, service := range tg.Services { 280 if service.Name == serviceName { 281 mapping := tg.Networks.Port(service.PortLabel) 282 port = mapping.Value 283 break 284 } 285 } 286 default: 287 for idx, task := range tg.Tasks { 288 if task.Name == taskName { 289 port += idx 290 break 291 } 292 } 293 } 294 return fmt.Sprintf("localhost:%d", port) 295 } 296 297 func (h *envoyBootstrapHook) writeConfig(filename, config string) error { 298 if err := ioutil.WriteFile(filename, []byte(config), 0440); err != nil { 299 _ = os.Remove(filename) 300 return err 301 } 302 return nil 303 } 304 305 func (h *envoyBootstrapHook) execute(cmd *exec.Cmd) (string, error) { 306 var ( 307 stdout bytes.Buffer 308 stderr bytes.Buffer 309 ) 310 311 cmd.Stdout = &stdout 312 cmd.Stderr = &stderr 313 314 if err := cmd.Run(); err != nil { 315 _, recoverable := err.(*exec.ExitError) 316 // ExitErrors are recoverable since they indicate the 317 // command was runnable but exited with a unsuccessful 318 // error code. 319 return stderr.String(), structs.NewRecoverableError(err, recoverable) 320 } 321 return stdout.String(), nil 322 } 323 324 // grpcAddress determines the Consul gRPC endpoint address to use. 325 // 326 // In host networking this will default to 127.0.0.1:8502. 327 // In bridge/cni networking this will default to unix://<socket>. 328 // In either case, CONSUL_GRPC_ADDR will override the default. 329 func (h *envoyBootstrapHook) grpcAddress(env map[string]string) string { 330 if address := env[grpcConsulVariable]; address != "" { 331 return address 332 } 333 334 tg := h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup) 335 switch tg.Networks[0].Mode { 336 case "host": 337 return grpcDefaultAddress 338 default: 339 return "unix://" + allocdir.AllocGRPCSocket 340 } 341 } 342 343 func (h *envoyBootstrapHook) proxyServiceID(group string, service *structs.Service) string { 344 return agentconsul.MakeAllocServiceID(h.alloc.ID, "group-"+group, service) 345 } 346 347 func (h *envoyBootstrapHook) newEnvoyBootstrapArgs( 348 group string, service *structs.Service, 349 grpcAddr, envoyAdminBind, siToken, filepath string, 350 ) envoyBootstrapArgs { 351 var ( 352 sidecarForID string // sidecar only 353 gateway string // gateway only 354 proxyID string // gateway only 355 ) 356 357 switch { 358 case service.Connect.HasSidecar(): 359 sidecarForID = h.proxyServiceID(group, service) 360 case service.Connect.IsIngress(): 361 proxyID = h.proxyServiceID(group, service) 362 gateway = "ingress" 363 case service.Connect.IsTerminating(): 364 proxyID = h.proxyServiceID(group, service) 365 gateway = "terminating" 366 } 367 368 h.logger.Debug("bootstrapping envoy", 369 "sidecar_for", service.Name, "bootstrap_file", filepath, 370 "sidecar_for_id", sidecarForID, "grpc_addr", grpcAddr, 371 "admin_bind", envoyAdminBind, "gateway", gateway, 372 "proxy_id", proxyID, 373 ) 374 375 return envoyBootstrapArgs{ 376 consulConfig: h.consulConfig, 377 sidecarFor: sidecarForID, 378 grpcAddr: grpcAddr, 379 envoyAdminBind: envoyAdminBind, 380 siToken: siToken, 381 gateway: gateway, 382 proxyID: proxyID, 383 } 384 } 385 386 // envoyBootstrapArgs is used to accumulate CLI arguments that will be passed 387 // along to the exec invocation of consul which will then generate the bootstrap 388 // configuration file for envoy. 389 type envoyBootstrapArgs struct { 390 consulConfig consulTransportConfig 391 sidecarFor string // sidecars only 392 grpcAddr string 393 envoyAdminBind string 394 siToken string 395 gateway string // gateways only 396 proxyID string // gateways only 397 } 398 399 // args returns the CLI arguments consul needs in the correct order, with the 400 // -token argument present or not present depending on whether it is set. 401 func (e envoyBootstrapArgs) args() []string { 402 arguments := []string{ 403 "connect", 404 "envoy", 405 "-grpc-addr", e.grpcAddr, 406 "-http-addr", e.consulConfig.HTTPAddr, 407 "-admin-bind", e.envoyAdminBind, 408 "-bootstrap", 409 } 410 411 if v := e.sidecarFor; v != "" { 412 arguments = append(arguments, "-sidecar-for", v) 413 } 414 415 if v := e.gateway; v != "" { 416 arguments = append(arguments, "-gateway", v) 417 } 418 419 if v := e.proxyID; v != "" { 420 arguments = append(arguments, "-proxy-id", v) 421 } 422 423 if v := e.siToken; v != "" { 424 arguments = append(arguments, "-token", v) 425 } 426 427 if v := e.consulConfig.CAFile; v != "" { 428 arguments = append(arguments, "-ca-file", v) 429 } 430 431 if v := e.consulConfig.CertFile; v != "" { 432 arguments = append(arguments, "-client-cert", v) 433 } 434 435 if v := e.consulConfig.KeyFile; v != "" { 436 arguments = append(arguments, "-client-key", v) 437 } 438 439 if v := e.consulConfig.Namespace; v != "" { 440 arguments = append(arguments, "-namespace", v) 441 } 442 443 return arguments 444 } 445 446 // env creates the context of environment variables to be used when exec-ing 447 // the consul command for generating the envoy bootstrap config. It is expected 448 // the value of os.Environ() is passed in to be appended to. Because these are 449 // appended at the end of what will be passed into Cmd.Env, they will override 450 // any pre-existing values (i.e. what the Nomad agent was launched with). 451 // https://golang.org/pkg/os/exec/#Cmd 452 func (e envoyBootstrapArgs) env(env []string) []string { 453 if v := e.consulConfig.Auth; v != "" { 454 env = append(env, fmt.Sprintf("%s=%s", "CONSUL_HTTP_AUTH", v)) 455 } 456 if v := e.consulConfig.SSL; v != "" { 457 env = append(env, fmt.Sprintf("%s=%s", "CONSUL_HTTP_SSL", v)) 458 } 459 if v := e.consulConfig.VerifySSL; v != "" { 460 env = append(env, fmt.Sprintf("%s=%s", "CONSUL_HTTP_SSL_VERIFY", v)) 461 } 462 if v := e.consulConfig.Namespace; v != "" { 463 env = append(env, fmt.Sprintf("%s=%s", "CONSUL_NAMESPACE", v)) 464 } 465 return env 466 } 467 468 // maybeLoadSIToken reads the SI token saved to disk in the secrets directory 469 // by the service identities prestart hook. This envoy bootstrap hook blocks 470 // until the sids hook completes, so if the SI token is required to exist (i.e. 471 // Consul ACLs are enabled), it will be in place by the time we try to read it. 472 func (h *envoyBootstrapHook) maybeLoadSIToken(task, dir string) (string, error) { 473 tokenPath := filepath.Join(dir, sidsTokenFile) 474 token, err := ioutil.ReadFile(tokenPath) 475 if err != nil { 476 if !os.IsNotExist(err) { 477 h.logger.Error("failed to load SI token", "task", task, "error", err) 478 return "", errors.Wrapf(err, "failed to load SI token for %s", task) 479 } 480 h.logger.Trace("no SI token to load", "task", task) 481 return "", nil // token file does not exist 482 } 483 h.logger.Trace("recovered pre-existing SI token", "task", task) 484 return string(token), nil 485 }