github.com/iqoqo/nomad@v0.11.3-0.20200911112621-d7021c74d101/client/allocrunner/taskrunner/envoybootstrap_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  	"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 envoyBootstrapConsulConfig 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  	// CAPath (dir) not supported by Nomad's config object
    34  }
    35  
    36  type envoyBootstrapHookConfig struct {
    37  	consul envoyBootstrapConsulConfig
    38  	alloc  *structs.Allocation
    39  	logger hclog.Logger
    40  }
    41  
    42  func decodeTriState(b *bool) string {
    43  	switch {
    44  	case b == nil:
    45  		return ""
    46  	case *b:
    47  		return "true"
    48  	default:
    49  		return "false"
    50  	}
    51  }
    52  
    53  func newEnvoyBootstrapHookConfig(alloc *structs.Allocation, consul *config.ConsulConfig, logger hclog.Logger) *envoyBootstrapHookConfig {
    54  	return &envoyBootstrapHookConfig{
    55  		alloc:  alloc,
    56  		logger: logger,
    57  		consul: envoyBootstrapConsulConfig{
    58  			HTTPAddr:  consul.Addr,
    59  			Auth:      consul.Auth,
    60  			SSL:       decodeTriState(consul.EnableSSL),
    61  			VerifySSL: decodeTriState(consul.VerifySSL),
    62  			CAFile:    consul.CAFile,
    63  			CertFile:  consul.CertFile,
    64  			KeyFile:   consul.KeyFile,
    65  		},
    66  	}
    67  }
    68  
    69  const (
    70  	envoyBaseAdminPort      = 19000
    71  	envoyAdminBindEnvPrefix = "NOMAD_ENVOY_ADMIN_ADDR_"
    72  )
    73  
    74  // envoyBootstrapHook writes the bootstrap config for the Connect Envoy proxy
    75  // sidecar.
    76  type envoyBootstrapHook struct {
    77  	// alloc is the allocation with the envoy task being bootstrapped.
    78  	alloc *structs.Allocation
    79  
    80  	// Bootstrapping Envoy requires talking directly to Consul to generate
    81  	// the bootstrap.json config. Runtime Envoy configuration is done via
    82  	// Consul's gRPC endpoint. There are many security parameters to configure
    83  	// before contacting Consul.
    84  	consulConfig envoyBootstrapConsulConfig
    85  
    86  	// logger is used to log things
    87  	logger hclog.Logger
    88  }
    89  
    90  func newEnvoyBootstrapHook(c *envoyBootstrapHookConfig) *envoyBootstrapHook {
    91  	return &envoyBootstrapHook{
    92  		alloc:        c.alloc,
    93  		consulConfig: c.consul,
    94  		logger:       c.logger.Named(envoyBootstrapHookName),
    95  	}
    96  }
    97  
    98  func (envoyBootstrapHook) Name() string {
    99  	return envoyBootstrapHookName
   100  }
   101  
   102  func (h *envoyBootstrapHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartRequest, resp *interfaces.TaskPrestartResponse) error {
   103  	if !req.Task.Kind.IsConnectProxy() {
   104  		// Not a Connect proxy sidecar
   105  		resp.Done = true
   106  		return nil
   107  	}
   108  
   109  	serviceName := req.Task.Kind.Value()
   110  	if serviceName == "" {
   111  		return errors.New("connect proxy sidecar does not specify service name")
   112  	}
   113  
   114  	tg := h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup)
   115  
   116  	var service *structs.Service
   117  	for _, s := range tg.Services {
   118  		if s.Name == serviceName {
   119  			service = s
   120  			break
   121  		}
   122  	}
   123  
   124  	if service == nil {
   125  		return errors.New("connect proxy sidecar task exists but no services configured with a sidecar")
   126  	}
   127  
   128  	h.logger.Debug("bootstrapping Connect proxy sidecar", "task", req.Task.Name, "service", serviceName)
   129  
   130  	//TODO Should connect directly to Consul if the sidecar is running on the host netns.
   131  	grpcAddr := "unix://" + allocdir.AllocGRPCSocket
   132  
   133  	// Envoy runs an administrative API on the loopback interface. If multiple sidecars
   134  	// are running, the bind addresses need to have unique ports.
   135  	// TODO: support running in host netns, using freeport to find available port
   136  	envoyAdminBind := buildEnvoyAdminBind(h.alloc, req.Task.Name)
   137  	resp.Env = map[string]string{
   138  		helper.CleanEnvVar(envoyAdminBindEnvPrefix+serviceName, '_'): envoyAdminBind,
   139  	}
   140  
   141  	// Envoy bootstrap configuration may contain a Consul token, so write
   142  	// it to the secrets directory like Vault tokens.
   143  	bootstrapFilePath := filepath.Join(req.TaskDir.SecretsDir, "envoy_bootstrap.json")
   144  
   145  	id := agentconsul.MakeAllocServiceID(h.alloc.ID, "group-"+tg.Name, service)
   146  
   147  	h.logger.Debug("bootstrapping envoy", "sidecar_for", service.Name, "bootstrap_file", bootstrapFilePath, "sidecar_for_id", id, "grpc_addr", grpcAddr, "admin_bind", envoyAdminBind)
   148  
   149  	siToken, err := h.maybeLoadSIToken(req.Task.Name, req.TaskDir.SecretsDir)
   150  	if err != nil {
   151  		h.logger.Error("failed to generate envoy bootstrap config", "sidecar_for", service.Name)
   152  		return errors.Wrap(err, "failed to generate envoy bootstrap config")
   153  	}
   154  	h.logger.Debug("check for SI token for task", "task", req.Task.Name, "exists", siToken != "")
   155  
   156  	bootstrapBuilder := envoyBootstrapArgs{
   157  		consulConfig:   h.consulConfig,
   158  		sidecarFor:     id,
   159  		grpcAddr:       grpcAddr,
   160  		envoyAdminBind: envoyAdminBind,
   161  		siToken:        siToken,
   162  	}
   163  
   164  	bootstrapArgs := bootstrapBuilder.args()
   165  	bootstrapEnv := bootstrapBuilder.env(os.Environ())
   166  
   167  	// Since Consul services are registered asynchronously with this task
   168  	// hook running, retry a small number of times with backoff.
   169  	for tries := 3; ; tries-- {
   170  
   171  		cmd := exec.CommandContext(ctx, "consul", bootstrapArgs...)
   172  		cmd.Env = bootstrapEnv
   173  
   174  		// Redirect output to secrets/envoy_bootstrap.json
   175  		fd, err := os.Create(bootstrapFilePath)
   176  		if err != nil {
   177  			return fmt.Errorf("error creating secrets/envoy_bootstrap.json for envoy: %v", err)
   178  		}
   179  		cmd.Stdout = fd
   180  
   181  		buf := bytes.NewBuffer(nil)
   182  		cmd.Stderr = buf
   183  
   184  		// Generate bootstrap
   185  		err = cmd.Run()
   186  
   187  		// Close bootstrap.json
   188  		fd.Close()
   189  
   190  		if err == nil {
   191  			// Happy path! Bootstrap was created, exit.
   192  			break
   193  		}
   194  
   195  		// Check for error from command
   196  		if tries == 0 {
   197  			h.logger.Error("error creating bootstrap configuration for Connect proxy sidecar", "error", err, "stderr", buf.String())
   198  
   199  			// Cleanup the bootstrap file. An errors here is not
   200  			// important as (a) we test to ensure the deletion
   201  			// occurs, and (b) the file will either be rewritten on
   202  			// retry or eventually garbage collected if the task
   203  			// fails.
   204  			os.Remove(bootstrapFilePath)
   205  
   206  			// ExitErrors are recoverable since they indicate the
   207  			// command was runnable but exited with a unsuccessful
   208  			// error code.
   209  			_, recoverable := err.(*exec.ExitError)
   210  			return structs.NewRecoverableError(
   211  				fmt.Errorf("error creating bootstrap configuration for Connect proxy sidecar: %v", err),
   212  				recoverable,
   213  			)
   214  		}
   215  
   216  		// Sleep before retrying to give Consul services time to register
   217  		select {
   218  		case <-time.After(2 * time.Second):
   219  		case <-ctx.Done():
   220  			// Killed before bootstrap, exit without setting Done
   221  			return nil
   222  		}
   223  	}
   224  
   225  	// Bootstrap written. Mark as done and move on.
   226  	resp.Done = true
   227  	return nil
   228  }
   229  
   230  func buildEnvoyAdminBind(alloc *structs.Allocation, taskName string) string {
   231  	port := envoyBaseAdminPort
   232  	for idx, task := range alloc.Job.LookupTaskGroup(alloc.TaskGroup).Tasks {
   233  		if task.Name == taskName {
   234  			port += idx
   235  			break
   236  		}
   237  	}
   238  	return fmt.Sprintf("localhost:%d", port)
   239  }
   240  
   241  func (h *envoyBootstrapHook) writeConfig(filename, config string) error {
   242  	if err := ioutil.WriteFile(filename, []byte(config), 0440); err != nil {
   243  		_ = os.Remove(filename)
   244  		return err
   245  	}
   246  	return nil
   247  }
   248  
   249  func (h *envoyBootstrapHook) execute(cmd *exec.Cmd) (string, error) {
   250  	var (
   251  		stdout bytes.Buffer
   252  		stderr bytes.Buffer
   253  	)
   254  
   255  	cmd.Stdout = &stdout
   256  	cmd.Stderr = &stderr
   257  
   258  	if err := cmd.Run(); err != nil {
   259  		_, recoverable := err.(*exec.ExitError)
   260  		// ExitErrors are recoverable since they indicate the
   261  		// command was runnable but exited with a unsuccessful
   262  		// error code.
   263  		return stderr.String(), structs.NewRecoverableError(err, recoverable)
   264  	}
   265  	return stdout.String(), nil
   266  }
   267  
   268  // envoyBootstrapArgs is used to accumulate CLI arguments that will be passed
   269  // along to the exec invocation of consul which will then generate the bootstrap
   270  // configuration file for envoy.
   271  type envoyBootstrapArgs struct {
   272  	consulConfig   envoyBootstrapConsulConfig
   273  	sidecarFor     string
   274  	grpcAddr       string
   275  	envoyAdminBind string
   276  	siToken        string
   277  }
   278  
   279  // args returns the CLI arguments consul needs in the correct order, with the
   280  // -token argument present or not present depending on whether it is set.
   281  func (e envoyBootstrapArgs) args() []string {
   282  	arguments := []string{
   283  		"connect",
   284  		"envoy",
   285  		"-grpc-addr", e.grpcAddr,
   286  		"-http-addr", e.consulConfig.HTTPAddr,
   287  		"-admin-bind", e.envoyAdminBind,
   288  		"-bootstrap",
   289  		"-sidecar-for", e.sidecarFor,
   290  	}
   291  
   292  	if v := e.siToken; v != "" {
   293  		arguments = append(arguments, "-token", v)
   294  	}
   295  
   296  	if v := e.consulConfig.CAFile; v != "" {
   297  		arguments = append(arguments, "-ca-file", v)
   298  	}
   299  
   300  	if v := e.consulConfig.CertFile; v != "" {
   301  		arguments = append(arguments, "-client-cert", v)
   302  	}
   303  
   304  	if v := e.consulConfig.KeyFile; v != "" {
   305  		arguments = append(arguments, "-client-key", v)
   306  	}
   307  
   308  	return arguments
   309  }
   310  
   311  // env creates the context of environment variables to be used when exec-ing
   312  // the consul command for generating the envoy bootstrap config. It is expected
   313  // the value of os.Environ() is passed in to be appended to. Because these are
   314  // appended at the end of what will be passed into Cmd.Env, they will override
   315  // any pre-existing values (i.e. what the Nomad agent was launched with).
   316  // https://golang.org/pkg/os/exec/#Cmd
   317  func (e envoyBootstrapArgs) env(env []string) []string {
   318  	if v := e.consulConfig.Auth; v != "" {
   319  		env = append(env, fmt.Sprintf("%s=%s", "CONSUL_HTTP_AUTH", v))
   320  	}
   321  	if v := e.consulConfig.SSL; v != "" {
   322  		env = append(env, fmt.Sprintf("%s=%s", "CONSUL_HTTP_SSL", v))
   323  	}
   324  	if v := e.consulConfig.VerifySSL; v != "" {
   325  		env = append(env, fmt.Sprintf("%s=%s", "CONSUL_HTTP_SSL_VERIFY", v))
   326  	}
   327  	return env
   328  }
   329  
   330  // maybeLoadSIToken reads the SI token saved to disk in the secrets directory
   331  // by the service identities prestart hook. This envoy bootstrap hook blocks
   332  // until the sids hook completes, so if the SI token is required to exist (i.e.
   333  // Consul ACLs are enabled), it will be in place by the time we try to read it.
   334  func (h *envoyBootstrapHook) maybeLoadSIToken(task, dir string) (string, error) {
   335  	tokenPath := filepath.Join(dir, sidsTokenFile)
   336  	token, err := ioutil.ReadFile(tokenPath)
   337  	if err != nil {
   338  		if !os.IsNotExist(err) {
   339  			h.logger.Error("failed to load SI token", "task", task, "error", err)
   340  			return "", errors.Wrapf(err, "failed to load SI token for %s", task)
   341  		}
   342  		h.logger.Trace("no SI token to load", "task", task)
   343  		return "", nil // token file does not exist
   344  	}
   345  	h.logger.Trace("recovered pre-existing SI token", "task", task)
   346  	return string(token), nil
   347  }