package config
import (
"bytes"
"flag"
"fmt"
"os"
"strings"
"testing"
"unicode"
"github.com/drone/envsubst/v2"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/grafana/agent/pkg/build"
"github.com/grafana/agent/pkg/config/features"
"github.com/grafana/agent/pkg/config/instrumentation"
"github.com/grafana/agent/pkg/logs"
"github.com/grafana/agent/pkg/metrics"
"github.com/grafana/agent/pkg/server"
"github.com/grafana/agent/pkg/traces"
"github.com/grafana/agent/pkg/util"
"github.com/prometheus/common/config"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v2"
)
var (
featRemoteConfigs = features.Feature("remote-configs")
featIntegrationsNext = features.Feature("integrations-next")
featExtraMetrics = features.Feature("extra-scrape-metrics")
featAgentManagement = features.Feature("agent-management")
allFeatures = []features.Feature{
featRemoteConfigs,
featIntegrationsNext,
featExtraMetrics,
featAgentManagement,
}
)
var (
fileTypeYAML = "yaml"
fileTypeDynamic = "dynamic"
fileTypes = []string{fileTypeYAML, fileTypeDynamic}
)
func DefaultConfig() Config {
defaultServerCfg := server.DefaultConfig()
return Config{
Server: &defaultServerCfg,
ServerFlags: server.DefaultFlags,
Metrics: metrics.DefaultConfig,
Integrations: DefaultVersionedIntegrations(),
DisableSupportBundle: false,
EnableConfigEndpoints: false,
EnableUsageReport: true,
}
}
type Config struct {
Server *server.Config `yaml:"server,omitempty"`
Metrics metrics.Config `yaml:"metrics,omitempty"`
Integrations VersionedIntegrations `yaml:"integrations,omitempty"`
Traces traces.Config `yaml:"traces,omitempty"`
Logs *logs.Config `yaml:"logs,omitempty"`
AgentManagement AgentManagementConfig `yaml:"agent_management,omitempty"`
ServerFlags server.Flags `yaml:"-"`
Deprecations []string `yaml:"-"`
BasicAuthUser string `yaml:"-"`
BasicAuthPassFile string `yaml:"-"`
EnableConfigEndpoints bool `yaml:"-"`
DisableSupportBundle bool `yaml:"-"`
EnableUsageReport bool `yaml:"-"`
EnabledFeatures []string `yaml:"-"`
}
func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
*c = DefaultConfig()
util.DefaultConfigFromFlags(c)
type baseConfig Config
type config struct {
baseConfig `yaml:",inline"`
Prometheus *metrics.Config `yaml:"prometheus,omitempty"`
Loki *logs.Config `yaml:"loki,omitempty"`
Tempo *traces.Config `yaml:"tempo,omitempty"`
}
var fc config
fc.baseConfig = baseConfig(*c)
if err := unmarshal(&fc); err != nil {
return err
}
if fc.Prometheus != nil && fc.Metrics.Unmarshaled && fc.Prometheus.Unmarshaled {
return fmt.Errorf("at most one of prometheus and metrics should be specified")
} else if fc.Prometheus != nil && fc.Prometheus.Unmarshaled {
fc.Deprecations = append(fc.Deprecations, "`prometheus` has been deprecated in favor of `metrics`")
fc.Metrics = *fc.Prometheus
fc.Prometheus = nil
}
if fc.Logs != nil && fc.Loki != nil {
return fmt.Errorf("at most one of loki and logs should be specified")
} else if fc.Logs == nil && fc.Loki != nil {
fc.Deprecations = append(fc.Deprecations, "`loki` has been deprecated in favor of `logs`")
fc.Logs = fc.Loki
fc.Loki = nil
}
if fc.Tempo != nil && fc.Traces.Unmarshaled {
return fmt.Errorf("at most one of tempo and traces should be specified")
} else if fc.Tempo != nil && fc.Tempo.Unmarshaled {
fc.Deprecations = append(fc.Deprecations, "`tempo` has been deprecated in favor of `traces`")
fc.Traces = *fc.Tempo
fc.Tempo = nil
}
*c = Config(fc.baseConfig)
return nil
}
func (c Config) MarshalYAML() (interface{}, error) {
var buf bytes.Buffer
enc := yaml.NewEncoder(&buf)
type config Config
if err := enc.Encode((config)(c)); err != nil {
return nil, err
}
var m yaml.MapSlice
if err := yaml.Unmarshal(buf.Bytes(), &m); err != nil {
return nil, err
}
return m, nil
}
func (c *Config) LogDeprecations(l log.Logger) {
for _, d := range c.Deprecations {
level.Warn(l).Log("msg", fmt.Sprintf("DEPRECATION NOTICE: %s", d))
}
}
func (c *Config) Validate(fs *flag.FlagSet) error {
if c.Server == nil {
return fmt.Errorf("an empty server config is invalid")
}
if err := c.Metrics.ApplyDefaults(); err != nil {
return err
}
if c.Logs != nil {
if err := c.Logs.ApplyDefaults(); err != nil {
return err
}
}
_, grpcPort, err := c.ServerFlags.GRPC.ListenHostPort()
if err != nil {
return err
}
c.Metrics.ServiceConfig.Lifecycler.ListenPort = grpcPort
if err := c.Integrations.ApplyDefaults(&c.ServerFlags, &c.Metrics); err != nil {
return err
}
if err := c.Traces.Validate(c.Logs); err != nil {
return err
}
if c.AgentManagement.Enabled {
if err := c.AgentManagement.Validate(); err != nil {
return fmt.Errorf("invalid agent management config: %w", err)
}
}
c.Metrics.ServiceConfig.APIEnableGetConfiguration = c.EnableConfigEndpoints
if fs == nil {
return nil
}
deps := []features.Dependency{
{Flag: "config.url.basic-auth-user", Feature: featRemoteConfigs},
{Flag: "config.url.basic-auth-password-file", Feature: featRemoteConfigs},
}
return features.Validate(fs, deps)
}
func (c *Config) RegisterFlags(f *flag.FlagSet) {
c.Metrics.RegisterFlags(f)
c.ServerFlags.RegisterFlags(f)
f.StringVar(&c.BasicAuthUser, "config.url.basic-auth-user", "",
"basic auth username for fetching remote config. (requires remote-configs experiment to be enabled")
f.StringVar(&c.BasicAuthPassFile, "config.url.basic-auth-password-file", "",
"path to file containing basic auth password for fetching remote config. (requires remote-configs experiment to be enabled")
f.BoolVar(&c.EnableConfigEndpoints, "config.enable-read-api", false, "Enables the /-/config and /agent/api/v1/configs/{name} APIs. Be aware that secrets could be exposed by enabling these endpoints!")
}
func LoadFile(filename string, expandEnvVars bool, c *Config) error {
buf, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("error reading config file %w", err)
}
instrumentation.InstrumentConfig(buf)
return LoadBytes(buf, expandEnvVars, c)
}
func loadFromAgentManagementAPI(path string, expandEnvVars bool, c *Config, log *server.Logger, fs *flag.FlagSet) error {
buf, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading initial config file %w", err)
}
err = LoadBytes(buf, expandEnvVars, c)
if err != nil {
return fmt.Errorf("failed to load initial config: %w", err)
}
configProvider, err := newRemoteConfigProvider(c)
if err != nil {
return err
}
remoteConfig, err := getRemoteConfig(expandEnvVars, configProvider, log, fs, true)
if err != nil {
return err
}
mergeEffectiveConfig(c, remoteConfig)
effectiveConfigBytes, err := yaml.Marshal(c)
if err != nil {
level.Warn(log).Log("msg", "error marshalling config for instrumenting config version", "err", err)
} else {
instrumentation.InstrumentConfig(effectiveConfigBytes)
}
return nil
}
func mergeEffectiveConfig(initialConfig *Config, remoteConfig *Config) {
initialConfig.Server = remoteConfig.Server
initialConfig.Metrics = remoteConfig.Metrics
initialConfig.Integrations = remoteConfig.Integrations
initialConfig.Traces = remoteConfig.Traces
initialConfig.Logs = remoteConfig.Logs
}
func LoadRemote(url string, expandEnvVars bool, c *Config) error {
remoteOpts := &remoteOpts{}
if c.BasicAuthUser != "" && c.BasicAuthPassFile != "" {
remoteOpts.HTTPClientConfig = &config.HTTPClientConfig{
BasicAuth: &config.BasicAuth{
Username: c.BasicAuthUser,
PasswordFile: c.BasicAuthPassFile,
},
}
}
if remoteOpts.HTTPClientConfig != nil {
dir, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current working directory: %w", err)
}
remoteOpts.HTTPClientConfig.SetDirectory(dir)
}
rc, err := newRemoteProvider(url, remoteOpts)
if err != nil {
return fmt.Errorf("error reading remote config: %w", err)
}
if rc == nil {
return LoadFile(url, expandEnvVars, c)
}
bb, err := rc.retrieve()
if err != nil {
return fmt.Errorf("error retrieving remote config: %w", err)
}
instrumentation.InstrumentConfig(bb)
return LoadBytes(bb, expandEnvVars, c)
}
func performEnvVarExpansion(buf []byte, expandEnvVars bool) ([]byte, error) {
if expandEnvVars {
s, err := envsubst.Eval(string(buf), getenv)
if err != nil {
return nil, fmt.Errorf("unable to substitute config with environment variables: %w", err)
}
return []byte(s), nil
}
return buf, nil
}
func LoadBytes(buf []byte, expandEnvVars bool, c *Config) error {
expandedBuf, err := performEnvVarExpansion(buf, expandEnvVars)
if err != nil {
return err
}
return yaml.UnmarshalStrict(expandedBuf, c)
}
func getenv(name string) string {
numericName := true
for _, r := range name {
if !unicode.IsDigit(r) {
numericName = false
break
}
}
if numericName {
return fmt.Sprintf("${%s}", name)
}
return os.Getenv(name)
}
func Load(fs *flag.FlagSet, args []string, log *server.Logger) (*Config, error) {
cfg, error := load(fs, args, func(path, fileType string, expandArgs bool, c *Config) error {
switch fileType {
case fileTypeYAML:
if features.Enabled(fs, featRemoteConfigs) {
return LoadRemote(path, expandArgs, c)
}
if features.Enabled(fs, featAgentManagement) {
return loadFromAgentManagementAPI(path, expandArgs, c, log, fs)
}
return LoadFile(path, expandArgs, c)
default:
return fmt.Errorf("unknown file type %q. accepted values: %s", fileType, strings.Join(fileTypes, ", "))
}
})
instrumentation.InstrumentLoad(error == nil)
return cfg, error
}
type loaderFunc func(path string, fileType string, expandArgs bool, target *Config) error
func applyIntegrationValuesFromFlagset(fs *flag.FlagSet, args []string, path string, cfg *Config) error {
if err := fs.Parse(args); err != nil {
return fmt.Errorf("error parsing flags: %w", err)
}
version := integrationsVersion1
if features.Enabled(fs, featIntegrationsNext) {
version = integrationsVersion2
}
if err := cfg.Integrations.setVersion(version); err != nil {
return fmt.Errorf("error loading config file %s: %w", path, err)
}
return nil
}
func load(fs *flag.FlagSet, args []string, loader loaderFunc) (*Config, error) {
var (
cfg = DefaultConfig()
printVersion bool
file string
fileType string
configExpandEnv bool
disableReporting bool
disableSupportBundles bool
)
fs.StringVar(&file, "config.file", "", "configuration file to load")
fs.StringVar(&fileType, "config.file.type", "yaml", fmt.Sprintf("Type of file pointed to by -config.file flag. Supported values: %s. %s requires dynamic-config and integrations-next features to be enabled.", strings.Join(fileTypes, ", "), fileTypeDynamic))
fs.BoolVar(&printVersion, "version", false, "Print this build's version information.")
fs.BoolVar(&configExpandEnv, "config.expand-env", false, "Expands ${var} in config according to the values of the environment variables.")
fs.BoolVar(&disableReporting, "disable-reporting", false, "Disable reporting of enabled feature flags to Grafana.")
fs.BoolVar(&disableSupportBundles, "disable-support-bundle", false, "Disable functionality for generating support bundles.")
cfg.RegisterFlags(fs)
features.Register(fs, allFeatures)
if err := fs.Parse(args); err != nil {
return nil, fmt.Errorf("error parsing flags: %w", err)
}
if printVersion {
fmt.Println(build.Print("agent"))
os.Exit(0)
}
if file == "" {
return nil, fmt.Errorf("-config.file flag required")
} else if err := loader(file, fileType, configExpandEnv, &cfg); err != nil {
return nil, fmt.Errorf("error loading config file %s: %w", file, err)
}
if err := applyIntegrationValuesFromFlagset(fs, args, file, &cfg); err != nil {
return nil, err
}
if features.Enabled(fs, featExtraMetrics) {
cfg.Metrics.Global.ExtraMetrics = true
}
if disableReporting {
cfg.EnableUsageReport = false
} else {
cfg.EnabledFeatures = features.GetAllEnabled(fs)
}
cfg.AgentManagement.Enabled = features.Enabled(fs, featAgentManagement)
if disableSupportBundles {
cfg.DisableSupportBundle = true
}
if err := cfg.Validate(fs); err != nil {
return nil, fmt.Errorf("error in config file: %w", err)
}
return &cfg, nil
}
func CheckSecret(t *testing.T, rawCfg string, originalValue string) {
var cfg Config
err := LoadBytes([]byte(rawCfg), false, &cfg)
require.NoError(t, err)
err = cfg.Integrations.setVersion(integrationsVersion1)
require.NoError(t, err)
bb, err := yaml.Marshal(&cfg)
require.NoError(t, err)
require.True(t, strings.Contains(string(bb), "<secret>"))
require.False(t, strings.Contains(string(bb), originalValue))
}