diff --git a/.gitignore b/.gitignore index e66eb7121f..c2a6a252ef 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,4 @@ website/components/node_modules .buildcache/ .releaser/ +*.log diff --git a/changelog/17841.txt b/changelog/17841.txt new file mode 100644 index 0000000000..444df41fc8 --- /dev/null +++ b/changelog/17841.txt @@ -0,0 +1,3 @@ +```release-note:feature +logging: Vault Agent supports logging to a specified file path via environment variable, CLI or config +``` \ No newline at end of file diff --git a/command/agent.go b/command/agent.go index b90665683c..9c7032c77d 100644 --- a/command/agent.go +++ b/command/agent.go @@ -41,11 +41,11 @@ import ( "github.com/hashicorp/vault/command/agent/sink/inmem" "github.com/hashicorp/vault/command/agent/template" "github.com/hashicorp/vault/command/agent/winsvc" + "github.com/hashicorp/vault/helper/logging" "github.com/hashicorp/vault/helper/metricsutil" "github.com/hashicorp/vault/internalshared/configutil" "github.com/hashicorp/vault/internalshared/listenerutil" "github.com/hashicorp/vault/sdk/helper/consts" - "github.com/hashicorp/vault/sdk/helper/logging" "github.com/hashicorp/vault/sdk/helper/useragent" "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/version" @@ -61,6 +61,12 @@ var ( _ cli.CommandAutocomplete = (*AgentCommand)(nil) ) +const ( + // flagNameAgentExitAfterAuth is used as an Agent specific flag to indicate + // that agent should exit after a single successful auth + flagNameAgentExitAfterAuth = "exit-after-auth" +) + type AgentCommand struct { *BaseCommand @@ -80,6 +86,7 @@ type AgentCommand struct { flagConfigs []string flagLogLevel string + flagLogFile string flagExitAfterAuth bool flagTestVerifyOnly bool @@ -124,17 +131,24 @@ func (c *AgentCommand) Flags() *FlagSets { }) f.StringVar(&StringVar{ - Name: "log-level", + Name: flagNameLogLevel, Target: &c.flagLogLevel, Default: "info", - EnvVar: "VAULT_LOG_LEVEL", + EnvVar: EnvVaultLogLevel, Completion: complete.PredictSet("trace", "debug", "info", "warn", "error"), Usage: "Log verbosity level. Supported values (in order of detail) are " + "\"trace\", \"debug\", \"info\", \"warn\", and \"error\".", }) + f.StringVar(&StringVar{ + Name: flagNameLogFile, + Target: &c.flagLogFile, + EnvVar: EnvVaultLogFile, + Usage: "Path to the log file that Vault should use for logging", + }) + f.BoolVar(&BoolVar{ - Name: "exit-after-auth", + Name: flagNameAgentExitAfterAuth, Target: &c.flagExitAfterAuth, Default: false, Usage: "If set to true, the agent will exit with code 0 after a single " + @@ -193,27 +207,6 @@ func (c *AgentCommand) Run(args []string) int { if c.flagCombineLogs { c.logWriter = os.Stdout } - var level log.Level - c.flagLogLevel = strings.ToLower(strings.TrimSpace(c.flagLogLevel)) - switch c.flagLogLevel { - case "trace": - level = log.Trace - case "debug": - level = log.Debug - case "notice", "info", "": - level = log.Info - case "warn", "warning": - level = log.Warn - case "err", "error": - level = log.Error - default: - c.UI.Error(fmt.Sprintf("Unknown log level: %s", c.flagLogLevel)) - return 1 - } - - if c.logger == nil { - c.logger = logging.NewVaultLoggerWithWriter(c.logWriter, level) - } // Validation if len(c.flagConfigs) != 1 { @@ -221,7 +214,7 @@ func (c *AgentCommand) Run(args []string) int { return 1 } - // Load the configuration + // Load the configuration file config, err := agentConfig.LoadConfig(c.flagConfigs[0]) if err != nil { c.UI.Error(fmt.Sprintf("Error loading configuration from %s: %s", c.flagConfigs[0], err)) @@ -235,6 +228,7 @@ func (c *AgentCommand) Run(args []string) int { "-config flag.")) return 1 } + if config.AutoAuth == nil && config.Cache == nil { c.UI.Error("No auto_auth or cache block found in config file") return 1 @@ -243,62 +237,29 @@ func (c *AgentCommand) Run(args []string) int { c.UI.Info("No auto_auth block found in config file, not starting automatic authentication feature") } - exitAfterAuth := config.ExitAfterAuth - f.Visit(func(fl *flag.Flag) { - if fl.Name == "exit-after-auth" { - exitAfterAuth = c.flagExitAfterAuth - } - }) + config = c.aggregateConfig(f, config) - c.setStringFlag(f, config.Vault.Address, &StringVar{ - Name: flagNameAddress, - Target: &c.flagAddress, - Default: "https://127.0.0.1:8200", - EnvVar: api.EnvVaultAddress, - }) - config.Vault.Address = c.flagAddress - c.setStringFlag(f, config.Vault.CACert, &StringVar{ - Name: flagNameCACert, - Target: &c.flagCACert, - Default: "", - EnvVar: api.EnvVaultCACert, - }) - config.Vault.CACert = c.flagCACert - c.setStringFlag(f, config.Vault.CAPath, &StringVar{ - Name: flagNameCAPath, - Target: &c.flagCAPath, - Default: "", - EnvVar: api.EnvVaultCAPath, - }) - config.Vault.CAPath = c.flagCAPath - c.setStringFlag(f, config.Vault.ClientCert, &StringVar{ - Name: flagNameClientCert, - Target: &c.flagClientCert, - Default: "", - EnvVar: api.EnvVaultClientCert, - }) - config.Vault.ClientCert = c.flagClientCert - c.setStringFlag(f, config.Vault.ClientKey, &StringVar{ - Name: flagNameClientKey, - Target: &c.flagClientKey, - Default: "", - EnvVar: api.EnvVaultClientKey, - }) - config.Vault.ClientKey = c.flagClientKey - c.setBoolFlag(f, config.Vault.TLSSkipVerify, &BoolVar{ - Name: flagNameTLSSkipVerify, - Target: &c.flagTLSSkipVerify, - Default: false, - EnvVar: api.EnvVaultSkipVerify, - }) - config.Vault.TLSSkipVerify = c.flagTLSSkipVerify - c.setStringFlag(f, config.Vault.TLSServerName, &StringVar{ - Name: flagTLSServerName, - Target: &c.flagTLSServerName, - Default: "", - EnvVar: api.EnvVaultTLSServerName, - }) - config.Vault.TLSServerName = c.flagTLSServerName + // Build the logger using level, format and path + logLevel, err := logging.ParseLogLevel(config.LogLevel) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + logFormat, err := logging.ParseLogFormat(config.LogFormat) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + logCfg := logging.NewLogConfig("agent", logLevel, logFormat, config.LogFile) + l, err := logging.Setup(logCfg, c.logWriter) + if err != nil { + c.UI.Error(err.Error()) + return 1 + } + + c.logger = l infoKeys := make([]string, 0, 10) info := make(map[string]string) @@ -855,16 +816,16 @@ func (c *AgentCommand) Run(args []string) int { ss := sink.NewSinkServer(&sink.SinkServerConfig{ Logger: c.logger.Named("sink.server"), Client: ahClient, - ExitAfterAuth: exitAfterAuth, + ExitAfterAuth: config.ExitAfterAuth, }) ts := template.NewServer(&template.ServerConfig{ Logger: c.logger.Named("template.server"), - LogLevel: level, + LogLevel: logLevel, LogWriter: c.logWriter, AgentConfig: config, Namespace: templateNamespace, - ExitAfterAuth: exitAfterAuth, + ExitAfterAuth: config.ExitAfterAuth, }) g.Add(func() error { @@ -963,6 +924,84 @@ func (c *AgentCommand) Run(args []string) int { return 0 } +// aggregateConfig ensures that the config object accurately reflects the desired +// settings as configured by the user. It applies the relevant config setting based +// on the precedence (env var overrides file config, cli overrides env var). +// It mutates the config object supplied and returns the updated object. +func (c *AgentCommand) aggregateConfig(f *FlagSets, config *agentConfig.Config) *agentConfig.Config { + f.Visit(func(fl *flag.Flag) { + if fl.Name == flagNameAgentExitAfterAuth { + config.ExitAfterAuth = c.flagExitAfterAuth + } + }) + + c.setStringFlag(f, config.LogFile, &StringVar{ + Name: flagNameLogFile, + EnvVar: EnvVaultLogFile, + Target: &c.flagLogFile, + }) + config.LogFile = c.flagLogFile + + c.setStringFlag(f, config.LogLevel, &StringVar{ + Name: flagNameLogLevel, + EnvVar: EnvVaultLogLevel, + Target: &c.flagLogLevel, + }) + config.LogLevel = c.flagLogLevel + + c.setStringFlag(f, config.Vault.Address, &StringVar{ + Name: flagNameAddress, + Target: &c.flagAddress, + Default: "https://127.0.0.1:8200", + EnvVar: api.EnvVaultAddress, + }) + config.Vault.Address = c.flagAddress + c.setStringFlag(f, config.Vault.CACert, &StringVar{ + Name: flagNameCACert, + Target: &c.flagCACert, + Default: "", + EnvVar: api.EnvVaultCACert, + }) + config.Vault.CACert = c.flagCACert + c.setStringFlag(f, config.Vault.CAPath, &StringVar{ + Name: flagNameCAPath, + Target: &c.flagCAPath, + Default: "", + EnvVar: api.EnvVaultCAPath, + }) + config.Vault.CAPath = c.flagCAPath + c.setStringFlag(f, config.Vault.ClientCert, &StringVar{ + Name: flagNameClientCert, + Target: &c.flagClientCert, + Default: "", + EnvVar: api.EnvVaultClientCert, + }) + config.Vault.ClientCert = c.flagClientCert + c.setStringFlag(f, config.Vault.ClientKey, &StringVar{ + Name: flagNameClientKey, + Target: &c.flagClientKey, + Default: "", + EnvVar: api.EnvVaultClientKey, + }) + config.Vault.ClientKey = c.flagClientKey + c.setBoolFlag(f, config.Vault.TLSSkipVerify, &BoolVar{ + Name: flagNameTLSSkipVerify, + Target: &c.flagTLSSkipVerify, + Default: false, + EnvVar: api.EnvVaultSkipVerify, + }) + config.Vault.TLSSkipVerify = c.flagTLSSkipVerify + c.setStringFlag(f, config.Vault.TLSServerName, &StringVar{ + Name: flagTLSServerName, + Target: &c.flagTLSServerName, + Default: "", + EnvVar: api.EnvVaultTLSServerName, + }) + config.Vault.TLSServerName = c.flagTLSServerName + + return config +} + // verifyRequestHeader wraps an http.Handler inside a Handler that checks for // the request header that is used for SSRF protection. func verifyRequestHeader(handler http.Handler) http.Handler { diff --git a/command/agent/config/config.go b/command/agent/config/config.go index b32772564b..3d8b96d3e0 100644 --- a/command/agent/config/config.go +++ b/command/agent/config/config.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io/ioutil" "net" "os" "strings" @@ -38,6 +37,7 @@ type Config struct { DisableKeepAlivesCaching bool `hcl:"-"` DisableKeepAlivesTemplating bool `hcl:"-"` DisableKeepAlivesAutoAuth bool `hcl:"-"` + LogFile string `hcl:"log_file"` } const ( @@ -173,7 +173,7 @@ func LoadConfig(path string) (*Config, error) { } // Read the file - d, err := ioutil.ReadFile(path) + d, err := os.ReadFile(path) if err != nil { return nil, err } diff --git a/command/agent/config/config_test.go b/command/agent/config/config_test.go index 16816f85e7..35a2ff8f15 100644 --- a/command/agent/config/config_test.go +++ b/command/agent/config/config_test.go @@ -230,6 +230,7 @@ func TestLoadConfigFile(t *testing.T) { NumRetries: 12, }, }, + LogFile: "/var/log/vault/vault-agent.log", } config.Prune() diff --git a/command/agent/config/test-fixtures/config-embedded-type.hcl b/command/agent/config/test-fixtures/config-embedded-type.hcl index 919bfd9077..4e6dc41f46 100644 --- a/command/agent/config/test-fixtures/config-embedded-type.hcl +++ b/command/agent/config/test-fixtures/config-embedded-type.hcl @@ -1,4 +1,5 @@ pid_file = "./pidfile" +log_file = "/var/log/vault/vault-agent.log" auto_auth { method "aws" { diff --git a/command/agent/config/test-fixtures/config.hcl b/command/agent/config/test-fixtures/config.hcl index b02170736a..ecfb88ae05 100644 --- a/command/agent/config/test-fixtures/config.hcl +++ b/command/agent/config/test-fixtures/config.hcl @@ -1,4 +1,5 @@ pid_file = "./pidfile" +log_file = "/var/log/vault/vault-agent.log" auto_auth { method { diff --git a/command/agent_test.go b/command/agent_test.go index c5138144ef..e11f452a49 100644 --- a/command/agent_test.go +++ b/command/agent_test.go @@ -20,6 +20,7 @@ import ( "github.com/hashicorp/vault/api" credAppRole "github.com/hashicorp/vault/builtin/credential/approle" "github.com/hashicorp/vault/command/agent" + agentConfig "github.com/hashicorp/vault/command/agent/config" vaulthttp "github.com/hashicorp/vault/http" "github.com/hashicorp/vault/sdk/helper/consts" "github.com/hashicorp/vault/sdk/helper/logging" @@ -27,9 +28,26 @@ import ( "github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/vault" "github.com/mitchellh/cli" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const ( + BasicHclConfig = ` +log_file = "/foo/bar/juan.log" +vault { + address = "http://127.0.0.1:8200" + retry { + num_retries = 5 + } +} + +listener "tcp" { + address = "127.0.0.1:8100" + tls_disable = true +}` +) + func testAgentCommand(tb testing.TB, logger hclog.Logger) (*cli.MockUi, *AgentCommand) { tb.Helper() @@ -1245,6 +1263,27 @@ func makeTempFile(t *testing.T, name, contents string) string { return path } +func populateTempFile(t *testing.T, name, contents string) *os.File { + t.Helper() + + file, err := os.CreateTemp(t.TempDir(), name) + if err != nil { + t.Fatal(err) + } + + _, err = file.WriteString(contents) + if err != nil { + t.Fatal(err) + } + + err = file.Close() + if err != nil { + t.Fatal(err) + } + + return file +} + // handler makes 500 errors happen for reads on /v1/secret. // Definitely not thread-safe, do not use t.Parallel with this. type handler struct { @@ -2211,6 +2250,105 @@ cache {} wg.Wait() } +func TestAgent_LogFile_EnvVarOverridesConfig(t *testing.T) { + // Create basic config + configFile := populateTempFile(t, "agent-config.hcl", BasicHclConfig) + cfg, err := agentConfig.LoadConfig(configFile.Name()) + if err != nil { + t.Fatal("Cannot load config to test update/merge", err) + } + + // Sanity check that the config value is the current value + assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile) + + // Make sure the env var is configured + oldEnvVarLogFile := os.Getenv(EnvVaultLogFile) + os.Setenv(EnvVaultLogFile, "/squiggle/logs.txt") + if oldEnvVarLogFile == "" { + defer os.Unsetenv(EnvVaultLogFile) + } else { + defer os.Setenv(EnvVaultLogFile, oldEnvVarLogFile) + } + + // Initialize the command and parse any flags + cmd := &AgentCommand{BaseCommand: &BaseCommand{}} + f := cmd.Flags() + err = f.Parse([]string{}) + if err != nil { + t.Fatal(err) + } + + // Update the config based on the inputs. + cfg = cmd.aggregateConfig(f, cfg) + + assert.NotEqual(t, "/foo/bar/juan.log", cfg.LogFile) + assert.Equal(t, "/squiggle/logs.txt", cfg.LogFile) +} + +func TestAgent_LogFile_CliOverridesEnvVar(t *testing.T) { + // Create basic config + configFile := populateTempFile(t, "agent-config.hcl", BasicHclConfig) + cfg, err := agentConfig.LoadConfig(configFile.Name()) + if err != nil { + t.Fatal("Cannot load config to test update/merge", err) + } + + // Sanity check that the config value is the current value + assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile) + + // Make sure the env var is configured + oldEnvVarLogFile := os.Getenv(EnvVaultLogFile) + os.Setenv(EnvVaultLogFile, "/squiggle/logs.txt") + if oldEnvVarLogFile == "" { + defer os.Unsetenv(EnvVaultLogFile) + } else { + defer os.Setenv(EnvVaultLogFile, oldEnvVarLogFile) + } + + // Initialize the command and parse any flags + cmd := &AgentCommand{BaseCommand: &BaseCommand{}} + f := cmd.Flags() + // Simulate the flag being specified + err = f.Parse([]string{"-log-file=/foo/bar/test.log"}) + if err != nil { + t.Fatal(err) + } + + // Update the config based on the inputs. + cfg = cmd.aggregateConfig(f, cfg) + + assert.NotEqual(t, "/foo/bar/juan.log", cfg.LogFile) + assert.NotEqual(t, "/squiggle/logs.txt", cfg.LogFile) + assert.Equal(t, "/foo/bar/test.log", cfg.LogFile) +} + +func TestAgent_LogFile_Config(t *testing.T) { + // Sanity check, remove any env var + os.Unsetenv(EnvVaultLogFile) + + configFile := populateTempFile(t, "agent-config.hcl", BasicHclConfig) + + cfg, err := agentConfig.LoadConfig(configFile.Name()) + if err != nil { + t.Fatal("Cannot load config to test update/merge", err) + } + + // Sanity check that the config value is the current value + assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile, "sanity check on log config failed") + + // Parse the cli flags (but we pass in an empty slice) + cmd := &AgentCommand{BaseCommand: &BaseCommand{}} + f := cmd.Flags() + err = f.Parse([]string{}) + if err != nil { + t.Fatal(err) + } + + cfg = cmd.aggregateConfig(f, cfg) + + assert.Equal(t, "/foo/bar/juan.log", cfg.LogFile, "actual config check") +} + // Get a randomly assigned port and then free it again before returning it. // There is still a race when trying to use it, but should work better // than a static port. diff --git a/command/commands.go b/command/commands.go index 9a64191d29..4e5500b12b 100644 --- a/command/commands.go +++ b/command/commands.go @@ -82,6 +82,11 @@ const ( EnvVaultLicensePath = "VAULT_LICENSE_PATH" // EnvVaultDetailed is to output detailed information (e.g., ListResponseWithInfo). EnvVaultDetailed = `VAULT_DETAILED` + // EnvVaultLogFile is used to specify the path to the log file that Vault should use for logging + EnvVaultLogFile = "VAULT_LOG_FILE" + // EnvVaultLogLevel is used to specify the log level applied to logging + // Supported log levels: Trace, Debug, Error, Warn, Info + EnvVaultLogLevel = "VAULT_LOG_LEVEL" // DisableSSCTokens is an env var used to disable index bearing // token functionality @@ -136,6 +141,11 @@ const ( flagNameUserLockoutDisable = "user-lockout-disable" // flagNameDisableRedirects is used to prevent the client from honoring a single redirect as a response to a request flagNameDisableRedirects = "disable-redirects" + // flagNameLogFile is used to specify the path to the log file that Vault should use for logging + flagNameLogFile = "log-file" + // flagNameLogLevel is used to specify the log level applied to logging + // Supported log levels: Trace, Debug, Error, Warn, Info + flagNameLogLevel = "log-level" ) var ( diff --git a/helper/logging/logfile.go b/helper/logging/logfile.go new file mode 100644 index 0000000000..b1fd46cbaf --- /dev/null +++ b/helper/logging/logfile.go @@ -0,0 +1,56 @@ +package logging + +import ( + "os" + "path/filepath" + "strings" + "sync" +) + +type LogFile struct { + // Name of the log file + fileName string + + // Path to the log file + logPath string + + // fileInfo is the pointer to the current file being written to + fileInfo *os.File + + // acquire is the mutex utilized to ensure we have no concurrency issues + acquire sync.Mutex +} + +func NewLogFile(logPath string, fileName string) *LogFile { + return &LogFile{ + fileName: strings.TrimSpace(fileName), + logPath: strings.TrimSpace(logPath), + } +} + +// Write is used to implement io.Writer +func (l *LogFile) Write(b []byte) (n int, err error) { + l.acquire.Lock() + defer l.acquire.Unlock() + // Create a new file if we have no file to write to + if l.fileInfo == nil { + if err := l.openNew(); err != nil { + return 0, err + } + } + + return l.fileInfo.Write(b) +} + +func (l *LogFile) openNew() error { + newFilePath := filepath.Join(l.logPath, l.fileName) + + // Try to open an existing file or create a new one if it doesn't exist. + filePointer, err := os.OpenFile(newFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) + if err != nil { + return err + } + + l.fileInfo = filePointer + return nil +} diff --git a/helper/logging/logfile_test.go b/helper/logging/logfile_test.go new file mode 100644 index 0000000000..442fc7c7c4 --- /dev/null +++ b/helper/logging/logfile_test.go @@ -0,0 +1,22 @@ +package logging + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLogFile_openNew(t *testing.T) { + logFile := NewLogFile(t.TempDir(), "vault-agent.log") + err := logFile.openNew() + require.NoError(t, err) + + msg := "[INFO] Something" + _, err = logFile.Write([]byte(msg)) + require.NoError(t, err) + + content, err := os.ReadFile(logFile.fileInfo.Name()) + require.NoError(t, err) + require.Contains(t, string(content), msg) +} diff --git a/helper/logging/logger.go b/helper/logging/logger.go new file mode 100644 index 0000000000..ec42ef0e11 --- /dev/null +++ b/helper/logging/logger.go @@ -0,0 +1,135 @@ +package logging + +import ( + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + log "github.com/hashicorp/go-hclog" +) + +const ( + UnspecifiedFormat LogFormat = iota + StandardFormat + JSONFormat +) + +type LogFormat int + +// LogConfig should be used to supply configuration when creating a new Vault logger +type LogConfig struct { + name string + logLevel log.Level + logFormat LogFormat + logFilePath string +} + +func NewLogConfig(name string, logLevel log.Level, logFormat LogFormat, logFilePath string) LogConfig { + return LogConfig{ + name: name, + logLevel: logLevel, + logFormat: logFormat, + logFilePath: strings.TrimSpace(logFilePath), + } +} + +func (c LogConfig) IsFormatJson() bool { + return c.logFormat == JSONFormat +} + +// Stringer implementation +func (lf LogFormat) String() string { + switch lf { + case UnspecifiedFormat: + return "unspecified" + case StandardFormat: + return "standard" + case JSONFormat: + return "json" + } + + // unreachable + return "unknown" +} + +// noErrorWriter is a wrapper to suppress errors when writing to w. +type noErrorWriter struct { + w io.Writer +} + +func (w noErrorWriter) Write(p []byte) (n int, err error) { + _, _ = w.w.Write(p) + // We purposely return n == len(p) as if write was successful + return len(p), nil +} + +// Setup creates a new logger with the specified configuration and writer +func Setup(config LogConfig, w io.Writer) (log.InterceptLogger, error) { + // Validate the log level + if config.logLevel.String() == "unknown" { + return nil, fmt.Errorf("invalid log level: %v", config.logLevel) + } + + // If out is os.Stdout and Vault is being run as a Windows Service, writes will + // fail silently, which may inadvertently prevent writes to other writers. + // noErrorWriter is used as a wrapper to suppress any errors when writing to out. + writers := []io.Writer{noErrorWriter{w: w}} + + if config.logFilePath != "" { + dir, fileName := filepath.Split(config.logFilePath) + if fileName == "" { + fileName = "vault-agent.log" + } + logFile := NewLogFile(dir, fileName) + if err := logFile.openNew(); err != nil { + return nil, fmt.Errorf("failed to set up file logging: %w", err) + } + writers = append(writers, logFile) + } + + logger := log.NewInterceptLogger(&log.LoggerOptions{ + Name: config.name, + Level: config.logLevel, + Output: io.MultiWriter(writers...), + JSONFormat: config.IsFormatJson(), + }) + return logger, nil +} + +// ParseLogFormat parses the log format from the provided string. +func ParseLogFormat(format string) (LogFormat, error) { + switch strings.ToLower(strings.TrimSpace(format)) { + case "": + return UnspecifiedFormat, nil + case "standard": + return StandardFormat, nil + case "json": + return JSONFormat, nil + default: + return UnspecifiedFormat, fmt.Errorf("unknown log format: %s", format) + } +} + +func ParseLogLevel(logLevel string) (log.Level, error) { + var result log.Level + logLevel = strings.ToLower(strings.TrimSpace(logLevel)) + + switch logLevel { + case "trace": + result = log.Trace + case "debug": + result = log.Debug + case "notice", "info", "": + result = log.Info + case "warn", "warning": + result = log.Warn + case "err", "error": + result = log.Error + default: + return -1, errors.New(fmt.Sprintf("unknown log level: %s", logLevel)) + } + + return result, nil +} diff --git a/helper/logging/logger_test.go b/helper/logging/logger_test.go new file mode 100644 index 0000000000..c300175f3e --- /dev/null +++ b/helper/logging/logger_test.go @@ -0,0 +1,128 @@ +package logging + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "testing" + + log "github.com/hashicorp/go-hclog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLogger_SetupBasic(t *testing.T) { + cfg := NewLogConfig("test-system", log.Info, StandardFormat, t.TempDir()+"test.log") + + logger, err := Setup(cfg, nil) + require.NoError(t, err) + require.NotNil(t, logger) +} + +func TestLogger_SetupInvalidLogLevel(t *testing.T) { + cfg := NewLogConfig("test-system", 999, StandardFormat, t.TempDir()+"test.log") + + _, err := Setup(cfg, nil) + assert.Containsf(t, err.Error(), "invalid log level", "expected error %s", err) +} + +func TestLogger_SetupLoggerErrorLevel(t *testing.T) { + cfg := NewLogConfig("test-system", log.Error, StandardFormat, t.TempDir()+"test.log") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.NoError(t, err) + require.NotNil(t, logger) + + logger.Error("test error msg") + logger.Info("test info msg") + + output := buf.String() + + require.Contains(t, output, "[ERROR] test-system: test error msg") + require.NotContains(t, output, "[INFO] test-system: test info msg") +} + +func TestLogger_SetupLoggerDebugLevel(t *testing.T) { + cfg := NewLogConfig("test-system", log.Debug, StandardFormat, t.TempDir()+"test.log") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.NoError(t, err) + require.NotNil(t, logger) + + logger.Info("test info msg") + logger.Debug("test debug msg") + + output := buf.String() + + require.Contains(t, output, "[INFO] test-system: test info msg") + require.Contains(t, output, "[DEBUG] test-system: test debug msg") +} + +func TestLogger_SetupLoggerWithName(t *testing.T) { + cfg := NewLogConfig("test-system", log.Debug, StandardFormat, t.TempDir()+"test.log") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.NoError(t, err) + require.NotNil(t, logger) + + logger.Warn("test warn msg") + + require.Contains(t, buf.String(), "[WARN] test-system: test warn msg") +} + +func TestLogger_SetupLoggerWithJSON(t *testing.T) { + cfg := NewLogConfig("test-system", log.Debug, JSONFormat, t.TempDir()+"test.log") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.NoError(t, err) + require.NotNil(t, logger) + + logger.Warn("test warn msg") + + var jsonOutput map[string]string + err = json.Unmarshal(buf.Bytes(), &jsonOutput) + require.NoError(t, err) + require.Contains(t, jsonOutput, "@level") + require.Equal(t, jsonOutput["@level"], "warn") + require.Contains(t, jsonOutput, "@message") + require.Equal(t, jsonOutput["@message"], "test warn msg") +} + +func TestLogger_SetupLoggerWithValidLogPath(t *testing.T) { + tmpDir := t.TempDir() + cfg := NewLogConfig("test-system", log.Info, StandardFormat, tmpDir+"/") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.NoError(t, err) + require.NotNil(t, logger) +} + +func TestLogger_SetupLoggerWithInValidLogPath(t *testing.T) { + cfg := NewLogConfig("test-system", log.Info, StandardFormat, "nonexistentdir/") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrNotExist)) + require.Nil(t, logger) +} + +func TestLogger_SetupLoggerWithInValidLogPathPermission(t *testing.T) { + tmpDir := "/tmp/" + t.Name() + + os.Mkdir(tmpDir, 0o000) + defer os.RemoveAll(tmpDir) + cfg := NewLogConfig("test-system", log.Info, StandardFormat, tmpDir+"/") + var buf bytes.Buffer + + logger, err := Setup(cfg, &buf) + require.Error(t, err) + require.True(t, errors.Is(err, os.ErrPermission)) + require.Nil(t, logger) +} diff --git a/website/content/docs/agent/index.mdx b/website/content/docs/agent/index.mdx index d41d9bdfeb..e087878fa8 100644 --- a/website/content/docs/agent/index.mdx +++ b/website/content/docs/agent/index.mdx @@ -127,6 +127,12 @@ See the [caching](/docs/agent/caching#api) page for details on the cache API. ## Configuration +### Command Options + +- `-log-file` `(string: "")` - If specified, should contain the full file path to use for outputting log files from Vault. + +### Configuration File Options + These are the currently-available general configuration option: - `vault` ([vault][vault]: ) - Specifies the remote Vault server the Agent connects to. @@ -373,4 +379,4 @@ template { [listener]: /docs/agent#listener-stanza [listener_main]: /docs/configuration/listener/tcp [winsvc]: /docs/agent/winsvc -[telemetry]: /docs/configuration/telemetry +[telemetry]: /docs/configuration/telemetry \ No newline at end of file diff --git a/website/content/docs/agent/winsvc.mdx b/website/content/docs/agent/winsvc.mdx index 7436c8b965..f67d7f9616 100644 --- a/website/content/docs/agent/winsvc.mdx +++ b/website/content/docs/agent/winsvc.mdx @@ -35,7 +35,7 @@ of Vault Agent as a service, using "Vault Agent" as the display name, and starti The `binPath` argument should include the fully qualified path to the Vault executable, as well as any arguments required. ```shell-session -PS C:\Windows\system32> sc.exe create VaultAgent binPath= "C:\vault\vault.exe agent -config=C:\vault\agent-config.hcl" displayName= "Vault Agent" start= auto +PS C:\Windows\system32> sc.exe create VaultAgent binPath="C:\vault\vault.exe agent -config=C:\vault\agent-config.hcl" displayName="Vault Agent" start=auto [SC] CreateService SUCCESS ``` diff --git a/website/content/docs/commands/index.mdx b/website/content/docs/commands/index.mdx index 1a5282c134..6be9cb004c 100644 --- a/website/content/docs/commands/index.mdx +++ b/website/content/docs/commands/index.mdx @@ -331,6 +331,10 @@ precedence over [#VAULT_LICENSE_PATH](#vault_license_path) and [Enterprise, Server only] Specify a path to a license on disk to use for this node. This takes precedence over [license_path in config](/docs/configuration#license_path). +### `VAULT_LOG_FILE` + +(Agent only) If provided, specifies the full path to a log file Vault should use to write its logs. + ### `VAULT_MAX_RETRIES` Maximum number of retries when certain error codes are encountered. The default @@ -434,4 +438,4 @@ list of available flags, run: ```shell-session $ vault -h -``` +``` \ No newline at end of file