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  }