1package taskrunner
2
3import (
4	"context"
5	"strings"
6
7	"github.com/hashicorp/go-hclog"
8	"github.com/hashicorp/go-version"
9	ifs "github.com/hashicorp/nomad/client/allocrunner/interfaces"
10	"github.com/hashicorp/nomad/client/consul"
11	"github.com/hashicorp/nomad/client/taskenv"
12	"github.com/hashicorp/nomad/helper/envoy"
13	"github.com/hashicorp/nomad/nomad/structs"
14	"github.com/pkg/errors"
15)
16
17const (
18	// envoyVersionHookName is the name of this hook and appears in logs.
19	envoyVersionHookName = "envoy_version"
20)
21
22type envoyVersionHookConfig struct {
23	alloc         *structs.Allocation
24	proxiesClient consul.SupportedProxiesAPI
25	logger        hclog.Logger
26}
27
28func newEnvoyVersionHookConfig(alloc *structs.Allocation, proxiesClient consul.SupportedProxiesAPI, logger hclog.Logger) *envoyVersionHookConfig {
29	return &envoyVersionHookConfig{
30		alloc:         alloc,
31		logger:        logger,
32		proxiesClient: proxiesClient,
33	}
34}
35
36// envoyVersionHook is used to determine and set the Docker image used for Consul
37// Connect sidecar proxy tasks. It will query Consul for a set of preferred Envoy
38// versions if the task image is unset or references ${NOMAD_envoy_version}. Nomad
39// will fallback the image to the previous default Envoy v1.11.2 if Consul is too old
40// to support the supported proxies API.
41type envoyVersionHook struct {
42	// alloc is the allocation with the envoy task being rewritten.
43	alloc *structs.Allocation
44
45	// proxiesClient is the subset of the Consul API for getting information
46	// from Consul about the versions of Envoy it supports.
47	proxiesClient consul.SupportedProxiesAPI
48
49	// logger is used to log things.
50	logger hclog.Logger
51}
52
53func newEnvoyVersionHook(c *envoyVersionHookConfig) *envoyVersionHook {
54	return &envoyVersionHook{
55		alloc:         c.alloc,
56		proxiesClient: c.proxiesClient,
57		logger:        c.logger.Named(envoyVersionHookName),
58	}
59}
60
61func (envoyVersionHook) Name() string {
62	return envoyVersionHookName
63}
64
65func (h *envoyVersionHook) Prestart(_ context.Context, request *ifs.TaskPrestartRequest, response *ifs.TaskPrestartResponse) error {
66	// First interpolation of the task image. Typically this turns the default
67	// ${meta.connect.sidecar_task} into envoyproxy/envoy:v${NOMAD_envoy_version}
68	// but could be a no-op or some other value if so configured.
69	h.interpolateImage(request.Task, request.TaskEnv)
70
71	// Detect whether this hook needs to run and return early if not. Only run if:
72	// - task uses docker driver
73	// - task is a connect sidecar or gateway
74	// - task image needs ${NOMAD_envoy_version} resolved
75	if h.skip(request) {
76		response.Done = true
77		return nil
78	}
79
80	// We either need to acquire Consul's preferred Envoy version or fallback
81	// to the legacy default. Query Consul and use the (possibly empty) result.
82	proxies, err := h.proxiesClient.Proxies()
83	if err != nil {
84		return errors.Wrap(err, "error retrieving supported Envoy versions from Consul")
85	}
86
87	// Second [pseudo] interpolation of task image. This determines the concrete
88	// Envoy image identifier by applying version string substitution of
89	// ${NOMAD_envoy_version} acquired from Consul.
90	image, err := h.tweakImage(h.taskImage(request.Task.Config), proxies)
91	if err != nil {
92		return errors.Wrap(err, "error interpreting desired Envoy version from Consul")
93	}
94
95	// Set the resulting image.
96	h.logger.Trace("setting task envoy image", "image", image)
97	request.Task.Config["image"] = image
98	response.Done = true
99	return nil
100}
101
102// interpolateImage applies the first pass of interpolation on the task's
103// config.image value. This is where ${meta.connect.sidecar_image} or
104// ${meta.connect.gateway_image} becomes something that might include the
105// ${NOMAD_envoy_version} pseudo variable for further resolution.
106func (_ *envoyVersionHook) interpolateImage(task *structs.Task, env *taskenv.TaskEnv) {
107	value, exists := task.Config["image"]
108	if !exists {
109		return
110	}
111
112	image, ok := value.(string)
113	if !ok {
114		return
115	}
116
117	task.Config["image"] = env.ReplaceEnv(image)
118}
119
120// skip will return true if the request does not contain a task that should have
121// its envoy proxy version resolved automatically.
122func (h *envoyVersionHook) skip(request *ifs.TaskPrestartRequest) bool {
123	switch {
124	case request.Task.Driver != "docker":
125		return true
126	case !request.Task.UsesConnectSidecar():
127		return true
128	case !h.needsVersion(request.Task.Config):
129		return true
130	}
131	return false
132}
133
134// getConfiguredImage extracts the configured config.image value from the request.
135// If the image is empty or not a string, Nomad will fallback to the normal
136// official Envoy image as if the setting was not configured. This is also what
137// Nomad would do if the sidecar_task was not set in the first place.
138func (h *envoyVersionHook) taskImage(config map[string]interface{}) string {
139	value, exists := config["image"]
140	if !exists {
141		return envoy.ImageFormat
142	}
143
144	image, ok := value.(string)
145	if !ok {
146		return envoy.ImageFormat
147	}
148
149	return image
150}
151
152// needsVersion returns true if the docker.config.image is making use of the
153// ${NOMAD_envoy_version} faux environment variable, or
154// Nomad does not need to query Consul to get the preferred Envoy version, etc.)
155func (h *envoyVersionHook) needsVersion(config map[string]interface{}) bool {
156	if len(config) == 0 {
157		return false
158	}
159
160	image := h.taskImage(config)
161
162	return strings.Contains(image, envoy.VersionVar)
163}
164
165// tweakImage determines the best Envoy version to use. If supported is nil or empty
166// Nomad will fallback to the legacy envoy image used before Nomad v1.0.
167func (h *envoyVersionHook) tweakImage(configured string, supported map[string][]string) (string, error) {
168	versions := supported["envoy"]
169	if len(versions) == 0 {
170		return envoy.FallbackImage, nil
171	}
172
173	latest, err := semver(versions[0])
174	if err != nil {
175		return "", err
176	}
177
178	return strings.ReplaceAll(configured, envoy.VersionVar, latest), nil
179}
180
181// semver sanitizes the envoy version string coming from Consul into the format
182// used by the Envoy project when publishing images (i.e. proper semver). This
183// resulting string value does NOT contain the 'v' prefix for 2 reasons:
184// 1) the version library does not include the 'v'
185// 2) its plausible unofficial images use the 3 numbers without the prefix for
186//    tagging their own images
187func semver(chosen string) (string, error) {
188	v, err := version.NewVersion(chosen)
189	if err != nil {
190		return "", errors.Wrap(err, "unexpected envoy version format")
191	}
192	return v.String(), nil
193}
194