Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix bug requiring $$ when using config source variables #1058

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 28 additions & 41 deletions cmd/otelcol/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
package main

import (
"bytes"
"flag"
"fmt"
"io"
Expand All @@ -30,12 +29,9 @@ import (
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configmapprovider"
"go.opentelemetry.io/collector/service"
"go.uber.org/zap"

"github.com/signalfx/splunk-otel-collector/internal/collectorconfig"
"github.com/signalfx/splunk-otel-collector/internal/components"
"github.com/signalfx/splunk-otel-collector/internal/configconverter"
"github.com/signalfx/splunk-otel-collector/internal/configprovider"
"github.com/signalfx/splunk-otel-collector/internal/configsources"
"github.com/signalfx/splunk-otel-collector/internal/version"
)

Expand Down Expand Up @@ -82,36 +78,43 @@ func main() {
Version: version.Version,
}

parserProvider := configprovider.NewConfigSourceParserProvider(
newBaseParserProvider(),
zap.NewNop(), // The service logger is not available yet, setting it to NoP.
serviceParams := service.CollectorSettings{
BuildInfo: info,
Factories: factories,
ConfigMapProvider: newConfigMapProvider(info),
}

if err := run(serviceParams); err != nil {
log.Fatal(err)
}
}

func newConfigMapProvider(info component.BuildInfo) configmapprovider.Provider {
return collectorconfig.NewConfigMapProvider(
info,
configsources.Get()...,
hasNoConvertFlag(),
getConfigPath(),
os.Getenv(configYamlEnvVarName),
getSetProperties(),
)
}

func hasNoConvertFlag() bool {
const noConvertConfigFlag = "--no-convert-config"
if hasFlag(noConvertConfigFlag) {
// the collector complains about this flag if we don't remove it
removeFlag(&os.Args, noConvertConfigFlag)
} else {
parserProvider = configconverter.ParserProvider(
parserProvider,
configconverter.RemoveBallastKey,
configconverter.MoveOTLPInsecureKey,
configconverter.MoveHecTLS,
configconverter.RenameK8sTagger,
)
}

serviceParams := service.CollectorSettings{
BuildInfo: info,
Factories: factories,
ConfigMapProvider: parserProvider,
return true
}
return false
}

if err := run(serviceParams); err != nil {
log.Fatal(err)
func getConfigPath() string {
ok, configPath := getKeyValue(os.Args[1:], "--config")
if !ok {
return os.Getenv(configEnvVarName)
}
return configPath
}

// required to support --set functionality no longer directly parsed by the core config loader.
Expand Down Expand Up @@ -370,22 +373,6 @@ func setDefaultEnvVars() {
}
}

// Returns a ParserProvider that reads configuration YAML from an environment variable when applicable.
func newBaseParserProvider() configmapprovider.Provider {
var configPath string
var ok bool
if ok, configPath = getKeyValue(os.Args[1:], "--config"); !ok {
configPath = os.Getenv(configEnvVarName)
}
configYaml := os.Getenv(configYamlEnvVarName)

if configPath == "" && configYaml != "" {
return configmapprovider.NewExpand(configmapprovider.NewInMemory(bytes.NewBufferString(configYaml)))
}

return configmapprovider.NewDefault(configPath, getSetProperties())
}

func runInteractive(settings service.CollectorSettings) error {
cmd := service.NewCommand(settings)
if err := cmd.Execute(); err != nil {
Expand Down
81 changes: 81 additions & 0 deletions internal/collectorconfig/config_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package collectorconfig

import (
"bytes"

"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configmapprovider"
"go.uber.org/zap"

"github.com/signalfx/splunk-otel-collector/internal/configconverter"
"github.com/signalfx/splunk-otel-collector/internal/configprovider"
"github.com/signalfx/splunk-otel-collector/internal/configsources"
)

func NewConfigMapProvider(
info component.BuildInfo,
hasNoConvertConfigFlag bool,
configFlagPath string,
configYamlFromEnv string,
properties []string,
) configmapprovider.Provider {
provider := newExpandProvider(info, configFlagPath, configYamlFromEnv, properties, hasNoConvertConfigFlag)
if !hasNoConvertConfigFlag {
provider = configconverter.Provider(
provider,
configconverter.RemoveBallastKey,
configconverter.MoveOTLPInsecureKey,
configconverter.MoveHecTLS,
configconverter.RenameK8sTagger,
)
}
return provider
}

func newExpandProvider(
info component.BuildInfo,
configPath string,
configYamlFromEnv string,
properties []string,
hasNoConvertConfigFlag bool,
) configmapprovider.Provider {
provider := newBaseProvider(configPath, configYamlFromEnv, properties)
if !hasNoConvertConfigFlag {
// we have to convert any $${foo:bar} *before* the expand provider runs,
// so we do it here rather than in NewConfigMapProvider
provider = configconverter.Provider(
provider,
configconverter.ReplaceDollarDollar,
)
}
return configmapprovider.NewExpand(configprovider.NewConfigSourceParserProvider(
provider,
zap.NewNop(), // The service logger is not available yet, setting it to NoP.
info,
configsources.Get()...,
))
}

func newBaseProvider(configPath string, configYamlFromEnv string, properties []string) configmapprovider.Provider {
if configPath == "" && configYamlFromEnv != "" {
return configmapprovider.NewInMemory(bytes.NewBufferString(configYamlFromEnv))
}
return configmapprovider.NewMerge(
configmapprovider.NewFile(configPath),
configmapprovider.NewProperties(properties),
)
}
52 changes: 52 additions & 0 deletions internal/collectorconfig/config_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package collectorconfig

import (
"context"
"os"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/component"
"go.opentelemetry.io/collector/config/configmapprovider"
)

func TestParseConfigSource_ConfigFile(t *testing.T) {
_ = os.Setenv("CFG_SRC", "my_cfg_src_val")
_ = os.Setenv("LEGACY", "my_legacy_val")
pp := NewConfigMapProvider(component.BuildInfo{}, false, "testdata/config.yaml", "", nil)
assertProviderOK(t, pp)
}

func TestParseConfigSource_InMemory(t *testing.T) {
cfgYaml, err := os.ReadFile("testdata/config.yaml")
require.NoError(t, err)
pp := NewConfigMapProvider(component.BuildInfo{}, false, "", string(cfgYaml), nil)
assertProviderOK(t, pp)
}

func assertProviderOK(t *testing.T, provider configmapprovider.Provider) {
ctx := context.Background()
retrieved, err := provider.Retrieve(ctx, nil)
require.NoError(t, err)
cfgMap, err := retrieved.Get(ctx)
require.NoError(t, err)
v := cfgMap.Get("config_source_env_key")
assert.Equal(t, "my_cfg_src_val", v)
v = cfgMap.Get("legacy_env_key")
assert.Equal(t, "my_legacy_val", v)
}
4 changes: 4 additions & 0 deletions internal/collectorconfig/testdata/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
config_sources:
env:
config_source_env_key: ${env:CFG_SRC}
legacy_env_key: $LEGACY
55 changes: 55 additions & 0 deletions internal/configconverter/dollar_dollar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package configconverter

import (
"log"
"regexp"

"go.opentelemetry.io/collector/config"
)

// ReplaceDollarDollar replaces any $${foo:MY_VAR} config source variables with
// ${foo:MY_VAR}. These might exist because of customers working around a bug
// in how the Collector expanded these variables.
func ReplaceDollarDollar(m *config.Map) *config.Map {
Comment on lines +24 to +27
Copy link
Contributor

@dmitryax dmitryax Dec 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not only ${foo:MY_VAR} that is affected. Any env variables added to the config currently has to have the following $${ENV_VAR} format to work properly, and, if user wants to have a dollar sign, it must be escaped as $$$$.

I'm not sure if we want to implicitly translate any of the use cases.

I would suggest just adding a warning message for any encountered value with $${ and $$$$ but do not change them implicitly. Also we should make a clear statement about fixing this bug as a breaking change, add a line to changelog and to the upgrade guideline.

@pmcollins @rmfitzpatrick WDYT?

Copy link
Contributor

@dmitryax dmitryax Dec 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added comment assuming that the use cases I outlined are fixed as well, let me know if it's not the case

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not only ${foo:MY_VAR} that is affected. Any env variables added to the config currently has to have the following $${ENV_VAR} format to work properly, and, if user wants to have a dollar sign, it must be escaped as $$$$.

I don't think this is accurate and $ENVVAR and ${ENVVAR} should be functional as is and after these changes (for example https://github.com/signalfx/splunk-otel-collector/blob/main/tests/general/testdata/config_with_envvars.yaml#L9 and https://github.com/signalfx/splunk-otel-collector/blob/main/cmd/otelcol/config/collector/agent_config.yaml#L25).

If users want to have a $ literal I think it makes sense that they'd need to escape it somehow and $$ makes the most sense to me. This should be clearly documented and ideally vetted with unit/integration tests.

Copy link
Contributor

@dmitryax dmitryax Dec 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is accurate and $ENVVAR and ${ENVVAR} should be functional as is and after these changes (for example https://github.com/signalfx/splunk-otel-collector/blob/main/tests/general/testdata/config_with_envvars.yaml#L9 and https://github.com/signalfx/splunk-otel-collector/blob/main/cmd/otelcol/config/collector/agent_config.yaml#L25).

The bug is that any env var is evaluated twice. So ${ENV_VAR} and $${ENV_VAR} both have the same result. Once ${ENV_VAR} evaluated first, second evaluation is skipped because then it's just a string.

If users want to have a $ literal I think it makes sense that they'd need to escape it somehow and $$ makes the most sense to me. This should be clearly documented and ideally vetted with unit/integration tests.

Yes, it should be $$ not $$$$ which we have to set because of this bug in the helm chart but it's not needed when contrib image is used

Copy link
Contributor

@dmitryax dmitryax Dec 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we can keep the implicit translation for some time, it shouldn't break existing configs, but in order to cover all the use cases we probably should just convert $$ -> $.

I would still clearly mark it in the changelog and translation guidelines and set expectation when this translation is removed. Let's say in 0.45.0?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

local testing with these changes:

config_sources:
  env:
    defaults:
      HOST_METRICS_SCRAPERS_TO_EXPAND: {}
      HOST_METRICS_SCRAPERS_DEFAULT_TO_USE: { cpu: null }

receivers:
  hostmetrics:
    collection_interval: $$HOST_METRICS_COLLECTION_INTERVAL
    scrapers: ${env:HOST_METRICS_SCRAPERS_TO_EXPAND}
  hostmetrics/default-env-config-source:
    collection_interval: $${HOST_METRICS_COLLECTION_INTERVAL}
    scrapers: $${env:HOST_METRICS_SCRAPERS_DEFAULT_TO_USE}

http://localhost:55554/debug/configz/effective:

receivers:
  hostmetrics:
    collection_interval: $HOST_METRICS_COLLECTION_INTERVAL
    scrapers:
      cpu: {}
      disk: {}
      filesystem: {}
      load: {}
      memory: {}
      network: {}
      paging: {}
      processes: {}
  hostmetrics/default-env-config-source:
    collection_interval: ${HOST_METRICS_COLLECTION_INTERVAL}
    scrapers:
      cpu: null

What should the expected behavior be if not this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR from my testing sometime ago http://localhost:55554/debug/configz/effective doesn't show results of the second evaluation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collector should not be able to start with the following part using contrib build

receivers:
  hostmetrics:
    collection_interval: $$HOST_METRICS_COLLECTION_INTERVAL

re := dollarDollarRegex()
replace := func(s string) string {
return replaceDollarDollar(re, s)
}
for _, k := range m.AllKeys() {
switch v := m.Get(k).(type) {
case string:
replaced := replace(v)
if replaced != v {
format := "[WARNING] the notation %q is no longer recommended. Please replace with %q.\n"
log.Printf(format, v, replaced)
m.Set(k, replaced)
}
case []interface{}:
replaced := replaceArray(v, replace)
m.Set(k, replaced)
}
}
return m
}

func dollarDollarRegex() *regexp.Regexp {
return regexp.MustCompile(`\$\${(.+?:.+?)}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it preferable to obtain via a function and not a global var?

}

func replaceDollarDollar(re *regexp.Regexp, s string) string {
return re.ReplaceAllString(s, "${$1}")
}
59 changes: 59 additions & 0 deletions internal/configconverter/dollar_dollar_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright The OpenTelemetry Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package configconverter

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/config/configmapprovider"
)

func TestReplaceDollarDollar(t *testing.T) {
p := &converterProvider{
wrapped: configmapprovider.NewFile("testdata/dollar-dollar.yaml"),
cfgMapFuncs: []CfgMapFunc{ReplaceDollarDollar},
}
r, err := p.Retrieve(context.Background(), nil)
require.NoError(t, err)
cfgMap, err := r.Get(context.Background())
require.NoError(t, err)
endpt := cfgMap.Get("exporters::otlp::endpoint")
assert.Equal(t, "localhost:${env:OTLP_PORT}", endpt)
insecure := cfgMap.Get("exporters::otlp::insecure")
assert.Equal(t, "$OTLP_INSECURE", insecure)
pwd := cfgMap.Get("receivers::redis::password")
assert.Equal(t, "$$ecret", pwd)
host := cfgMap.Get("receivers::redis::host")
assert.Equal(t, "ho$$tname:${env:PORT}", host)
v := cfgMap.Get("processors::metricstransform::transforms").([]interface{})[0]
transforms := v.(map[string]interface{})
operations := transforms["operations"].([]interface{})
op0 := operations[0].(map[string]interface{})
assert.Equal(t, "${env:MYVAR}", op0["new_value"])
op1 := operations[0].(map[string]interface{})
assert.Equal(t, "yyy${env:MYVAR}zzz", op1["new_value"])
}

func TestRegexReplace(t *testing.T) {
assert.Equal(t, "${foo/bar:PORT}", testReplace("$${foo/bar:PORT}"))
assert.Equal(t, "$${PORT}", testReplace("$${PORT}"))
}

func testReplace(s string) string {
return replaceDollarDollar(dollarDollarRegex(), s)
}
Loading