Skip to content

Commit 4bd5b56

Browse files
authored
Config Source Manager: support current env var syntax (#2903)
Adding support to existing env var syntax per #2857 (comment)
1 parent 9c3d27f commit 4bd5b56

File tree

4 files changed

+460
-55
lines changed

4 files changed

+460
-55
lines changed

config/internal/configsource/manager.go

Lines changed: 266 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"errors"
2121
"fmt"
2222
"net/url"
23+
"os"
2324
"strings"
2425
"sync"
2526

@@ -29,6 +30,20 @@ import (
2930
"go.opentelemetry.io/collector/consumer/consumererror"
3031
)
3132

33+
const (
34+
// expandPrefixChar is the char used to prefix strings that can be expanded,
35+
// either environment variables or config sources.
36+
expandPrefixChar = '$'
37+
// configSourceNameDelimChar is the char used to terminate the name of config source
38+
// when it is used to retrieve values to inject in the configuration
39+
configSourceNameDelimChar = ':'
40+
)
41+
42+
// private error types to help with testability
43+
type (
44+
errUnknownConfigSource struct{ error }
45+
)
46+
3247
// Manager is used to inject data from config sources into a configuration and also
3348
// to monitor for updates on the items injected into the configuration. All methods
3449
// of a Manager must be called only once and have a expected sequence:
@@ -44,13 +59,17 @@ import (
4459
//
4560
// param_to_be_retrieved: $<cfgSrcName>:<selector>[?<params_url_query_format>]
4661
//
62+
// bracketed single-line:
63+
//
64+
// param_to_be_retrieved: ${<cfgSrcName>:<selector>[?<params_url_query_format>]}
65+
//
4766
// and multi-line are supported:
4867
//
4968
// param_to_be_retrieved: |
5069
// $<cfgSrcName>: <selector>
5170
// [<params_multi_line_YAML>]
5271
//
53-
// The <cfgSrcName> is a name string used to indentify the config source instance to be used
72+
// The <cfgSrcName> is a name string used to identify the config source instance to be used
5473
// to retrieve the value.
5574
//
5675
// The <selector> is the mandatory parameter required when retrieving data from a config source.
@@ -64,7 +83,7 @@ import (
6483
// component:
6584
// config_field: $file:/etc/secret.bin?binary=true
6685
//
67-
// For mult-line format <params_multi_line_YAML> uses syntax as a YAML inside YAML. Possible usage
86+
// For multi-line format <params_multi_line_YAML> uses syntax as a YAML inside YAML. Possible usage
6887
// example in a YAML file:
6988
//
7089
// component:
@@ -91,6 +110,48 @@ import (
91110
// # as a string if params doesn't specify that "binary" is true.
92111
// text_from_file: $file:/etc/text.txt
93112
//
113+
// Bracketed single-line should be used when concatenating a suffix to the value retrieved by
114+
// the config source. Example:
115+
//
116+
// component:
117+
// # Retrieves the value of the environment variable LOGS_DIR and appends /component.log to it.
118+
// log_file_fullname: ${env:LOGS_DIR}/component.log
119+
//
120+
// Environment variables are expanded before passed to the config source when used in the selector or
121+
// the optional parameters. Example:
122+
//
123+
// component:
124+
// # Retrieves the value from the file text.txt located on the path specified by the environment
125+
// # variable DATA_PATH. The name of the environment variable is the string after the delimiter
126+
// # until the first character different than '_' and non-alpha-numeric.
127+
// text_from_file: $file:$DATA_PATH/text.txt
128+
//
129+
// Since environment variables and config sources both use the '$', with or without brackets, as a prefix
130+
// for their expansion it is necessary to have a way to distinguish between them. For the non-bracketed
131+
// syntax the code will peek at the first character other than alpha-numeric and '_' after the '$'. If
132+
// that character is a ':' it will treat it as a config source and as environment variable otherwise.
133+
// For example:
134+
//
135+
// component:
136+
// field_0: $PATH:/etc/logs # Injects the data from a config sourced named "PATH" using the selector "/etc/logs".
137+
// field_1: $PATH/etc/logs # Expands the environment variable "PATH" and adds the suffix "/etc/logs" to it.
138+
//
139+
// So if you need to include an environment followed by ':' the bracketed syntax must be used instead:
140+
//
141+
// component:
142+
// field_0: ${PATH}:/etc/logs # Expands the environment variable "PATH" and adds the suffix ":/etc/logs" to it.
143+
//
144+
// For the bracketed syntax the presence of ':' inside the brackets indicates that code will treat the bracketed
145+
// contents as a config source. For example:
146+
//
147+
// component:
148+
// field_0: ${file:/var/secret.txt} # Injects the data from a config sourced named "file" using the selector "/var/secret.txt".
149+
// field_1: ${file}:/var/secret.txt # Expands the environment variable "file" and adds the suffix ":/var/secret.txt" to it.
150+
//
151+
// If the the character following the '$' is in the set {'*', '#', '$', '@', '!', '?', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'}
152+
// the code will consider it to be the name of an environment variable to expand, or config source if followed by ':'. Do not use any of these
153+
// characters as the first char on the name of a config source or an environment variable (even if allowed by the system) to avoid unexpected
154+
// results.
94155
type Manager struct {
95156
// configSources is map from ConfigSource names (as defined in the configuration)
96157
// and the respective instances.
@@ -114,7 +175,7 @@ type Manager struct {
114175
// NewManager creates a new instance of a Manager to be used to inject data from
115176
// ConfigSource objects into a configuration and watch for updates on the injected
116177
// data.
117-
func NewManager(*config.Parser) (*Manager, error) {
178+
func NewManager(_ *config.Parser) (*Manager, error) {
118179
// TODO: Config sources should be extracted for the config itself, need Factories for that.
119180

120181
return &Manager{
@@ -236,7 +297,7 @@ func (m *Manager) retrieveEndAllSessions(ctx context.Context) []error {
236297
func (m *Manager) expandStringValues(ctx context.Context, value interface{}) (interface{}, error) {
237298
switch v := value.(type) {
238299
case string:
239-
return m.expandConfigSources(ctx, v)
300+
return m.expandString(ctx, v)
240301
case []interface{}:
241302
nslice := make([]interface{}, 0, len(v))
242303
for _, vint := range v {
@@ -262,32 +323,16 @@ func (m *Manager) expandStringValues(ctx context.Context, value interface{}) (in
262323
}
263324
}
264325

265-
// expandConfigSources retrieve data from the specified config sources and injects them into
326+
// expandConfigSource retrieve data from the specified config source and injects them into
266327
// the configuration. The Manager tracks sessions and watcher objects as needed.
267-
func (m *Manager) expandConfigSources(ctx context.Context, s string) (interface{}, error) {
268-
// Provisional implementation: only strings prefixed with the first character '$'
269-
// are checked for config sources.
270-
//
271-
// TODO: Handle concatenated with other strings (needs delimiter syntax);
272-
//
273-
if len(s) == 0 || s[0] != '$' {
274-
// TODO: handle escaped $.
275-
return s, nil
276-
}
277-
278-
cfgSrcName, selector, params, err := parseCfgSrc(s[1:])
328+
func (m *Manager) expandConfigSource(ctx context.Context, cfgSrc ConfigSource, s string) (interface{}, error) {
329+
cfgSrcName, selector, params, err := parseCfgSrc(s)
279330
if err != nil {
280331
return nil, err
281332
}
282333

283334
session, ok := m.sessions[cfgSrcName]
284335
if !ok {
285-
// The session for this config source was not created yet.
286-
cfgSrc, ok := m.configSources[cfgSrcName]
287-
if !ok {
288-
return nil, fmt.Errorf("config source %q not found", cfgSrcName)
289-
}
290-
291336
session, err = cfgSrc.NewSession(ctx)
292337
if err != nil {
293338
return nil, fmt.Errorf("failed to create session for config source %q: %w", cfgSrcName, err)
@@ -305,12 +350,129 @@ func (m *Manager) expandConfigSources(ctx context.Context, s string) (interface{
305350
return retrieved.Value(), nil
306351
}
307352

353+
// expandString expands environment variables and config sources that are specified on the string.
354+
func (m *Manager) expandString(ctx context.Context, s string) (interface{}, error) {
355+
// Code based on os.Expand function. All delimiters that are checked against are
356+
// ASCII so bytes are fine for this operation.
357+
var buf []byte
358+
359+
// Using i, j, and w variables to keep correspondence with os.Expand code.
360+
// i tracks the index in s from which a slice to be appended to buf should start.
361+
// j tracks the char being currently checked and also the end of the slice to be appended to buf.
362+
// w tracks the number of characters being consumed after a prefix identifying env vars or config sources.
363+
i := 0
364+
for j := 0; j < len(s); j++ {
365+
if s[j] == expandPrefixChar && j+1 < len(s) {
366+
if buf == nil {
367+
// Assuming that the length of the string will double after expansion of env vars and config sources.
368+
buf = make([]byte, 0, 2*len(s))
369+
}
370+
371+
// Append everything consumed up to the prefix char (but not including the prefix char) to the result.
372+
buf = append(buf, s[i:j]...)
373+
374+
var expandableContent, cfgSrcName string
375+
w := 0 // number of bytes consumed on this pass
376+
377+
switch {
378+
case s[j+1] == expandPrefixChar:
379+
// Escaping the prefix so $$ becomes a single $ without attempting
380+
// to treat the string after it as a config source or env var.
381+
expandableContent = string(expandPrefixChar)
382+
w = 1 // consumed a single char
383+
384+
case s[j+1] == '{':
385+
// Bracketed usage, consume everything until first '}' exactly as os.Expand.
386+
expandableContent, w = getShellName(s[j+1:])
387+
expandableContent = strings.Trim(expandableContent, " ") // Allow for some spaces.
388+
if len(expandableContent) > 1 && strings.Contains(expandableContent, string(configSourceNameDelimChar)) {
389+
// Bracket expandableContent contains ':' treating it as a config source.
390+
cfgSrcName, _ = getShellName(expandableContent)
391+
}
392+
393+
default:
394+
// Non-bracketed usage, ie.: found the prefix char, it can be either a config
395+
// source or an environment variable.
396+
var name string
397+
name, w = getShellName(s[j+1:])
398+
expandableContent = name // Assume for now that it is an env var.
399+
400+
// Peek next char after name, if it is a config source name delimiter treat the remaining of the
401+
// string as a config source.
402+
if j+w+1 < len(s) && s[j+w+1] == configSourceNameDelimChar {
403+
// This is a config source, since it is not delimited it will consume until end of the string.
404+
cfgSrcName = name
405+
expandableContent = s[j+1:]
406+
w = len(expandableContent) // Set consumed bytes to the length of expandableContent
407+
}
408+
}
409+
410+
switch {
411+
case cfgSrcName == "":
412+
// Not a config source, expand as os.ExpandEnv
413+
buf = osExpandEnv(buf, expandableContent, w)
414+
415+
default:
416+
// A config source, retrieve and apply results.
417+
retrieved, err := m.retrieveConfigSourceData(ctx, cfgSrcName, expandableContent)
418+
if err != nil {
419+
return nil, err
420+
}
421+
422+
consumedAll := j+w+1 == len(s)
423+
if consumedAll && len(buf) == 0 {
424+
// This is the only expandableContent on the string, config
425+
// source is free to return interface{}.
426+
return retrieved, nil
427+
}
428+
429+
// Either there was a prefix already or there are still
430+
// characters to be processed.
431+
buf = append(buf, fmt.Sprintf("%v", retrieved)...)
432+
}
433+
434+
j += w // move the index of the char being checked (j) by the number of characters consumed (w) on this iteration.
435+
i = j + 1 // update start index (i) of next slice of bytes to be copied.
436+
}
437+
}
438+
439+
if buf == nil {
440+
// No changes to original string, just return it.
441+
return s, nil
442+
}
443+
444+
// Return whatever was accumulated on the buffer plus the remaining of the original string.
445+
return string(buf) + s[i:], nil
446+
}
447+
448+
func (m *Manager) retrieveConfigSourceData(ctx context.Context, name, cfgSrcInvoke string) (interface{}, error) {
449+
cfgSrc, ok := m.configSources[name]
450+
if !ok {
451+
return nil, newErrUnknownConfigSource(name)
452+
}
453+
454+
// Expand any env vars on the selector and parameters. Nested config source usage
455+
// is not supported.
456+
cfgSrcInvoke = expandEnvVars(cfgSrcInvoke)
457+
retrieved, err := m.expandConfigSource(ctx, cfgSrc, cfgSrcInvoke)
458+
if err != nil {
459+
return nil, err
460+
}
461+
462+
return retrieved, nil
463+
}
464+
465+
func newErrUnknownConfigSource(cfgSrcName string) error {
466+
return &errUnknownConfigSource{
467+
fmt.Errorf(`config source %q not found if this was intended to be an environment variable use "${%s}" instead"`, cfgSrcName, cfgSrcName),
468+
}
469+
}
470+
308471
// parseCfgSrc extracts the reference to a config source from a string value.
309472
// The caller should check for error explicitly since it is possible for the
310473
// other values to have been partially set.
311474
func parseCfgSrc(s string) (cfgSrcName, selector string, params interface{}, err error) {
312-
const cfgSrcDelim string = ":"
313-
parts := strings.SplitN(s, cfgSrcDelim, 2)
475+
parts := strings.SplitN(s, string(configSourceNameDelimChar), 2)
314476
if len(parts) != 2 {
315477
err = fmt.Errorf("invalid config source syntax at %q, it must have at least the config source name and a selector", s)
316478
return
@@ -386,3 +548,82 @@ func parseParamsAsURLQuery(s string) (interface{}, error) {
386548
}
387549
return params, err
388550
}
551+
552+
// expandEnvVars is used to expand environment variables with the same syntax used
553+
// by config.Parser.
554+
func expandEnvVars(s string) string {
555+
return os.Expand(s, func(str string) string {
556+
// This allows escaping environment variable substitution via $$, e.g.
557+
// - $FOO will be substituted with env var FOO
558+
// - $$FOO will be replaced with $FOO
559+
// - $$$FOO will be replaced with $ + substituted env var FOO
560+
if str == "$" {
561+
return "$"
562+
}
563+
return os.Getenv(str)
564+
})
565+
}
566+
567+
// osExpandEnv replicate the internal behavior of os.ExpandEnv when handling env
568+
// vars updating the buffer accordingly.
569+
func osExpandEnv(buf []byte, name string, w int) []byte {
570+
switch {
571+
case name == "" && w > 0:
572+
// Encountered invalid syntax; eat the
573+
// characters.
574+
case name == "" || name == "$":
575+
// Valid syntax, but $ was not followed by a
576+
// name. Leave the dollar character untouched.
577+
buf = append(buf, expandPrefixChar)
578+
default:
579+
buf = append(buf, os.Getenv(name)...)
580+
}
581+
582+
return buf
583+
}
584+
585+
// Below are helper functions used by os.Expand, copied without changes from original sources (env.go).
586+
587+
// isShellSpecialVar reports whether the character identifies a special
588+
// shell variable such as $*.
589+
func isShellSpecialVar(c uint8) bool {
590+
switch c {
591+
case '*', '#', '$', '@', '!', '?', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
592+
return true
593+
}
594+
return false
595+
}
596+
597+
// isAlphaNum reports whether the byte is an ASCII letter, number, or underscore
598+
func isAlphaNum(c uint8) bool {
599+
return c == '_' || '0' <= c && c <= '9' || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z'
600+
}
601+
602+
// getShellName returns the name that begins the string and the number of bytes
603+
// consumed to extract it. If the name is enclosed in {}, it's part of a ${}
604+
// expansion and two more bytes are needed than the length of the name.
605+
func getShellName(s string) (string, int) {
606+
switch {
607+
case s[0] == '{':
608+
if len(s) > 2 && isShellSpecialVar(s[1]) && s[2] == '}' {
609+
return s[1:2], 3
610+
}
611+
// Scan to closing brace
612+
for i := 1; i < len(s); i++ {
613+
if s[i] == '}' {
614+
if i == 1 {
615+
return "", 2 // Bad syntax; eat "${}"
616+
}
617+
return s[1:i], i + 1
618+
}
619+
}
620+
return "", 1 // Bad syntax; eat "${"
621+
case isShellSpecialVar(s[0]):
622+
return s[0:1], 1
623+
}
624+
// Scan alphanumerics.
625+
var i int
626+
for i = 0; i < len(s) && isAlphaNum(s[i]); i++ {
627+
}
628+
return s[:i], i
629+
}

0 commit comments

Comments
 (0)