client: container: support CLI with entrypoint addition (#4126)

This will allow running netbird commands (including debugging) against the daemon and provide a flow similar to non-container usages.

It will by default both log to file and stderr so it can be handled more uniformly in container-native environments.
This commit is contained in:
Krzysztof Nazarewski (kdn)
2025-07-25 11:44:30 +02:00
committed by GitHub
parent 3f82698089
commit af8687579b
29 changed files with 267 additions and 66 deletions

3
.dockerignore-client Normal file
View File

@ -0,0 +1,3 @@
*
!client/netbird-entrypoint.sh
!netbird

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ infrastructure_files/setup-*.env
.vscode
.DS_Store
vendor/
/netbird

View File

@ -155,13 +155,15 @@ dockers:
goarch: amd64
use: buildx
dockerfile: client/Dockerfile
extra_files:
- client/netbird-entrypoint.sh
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source=https://github.com/netbirdio/{{.ProjectName}}"
- "--label=maintainer=dev@netbird.io"
- image_templates:
- netbirdio/netbird:{{ .Version }}-arm64v8
@ -171,6 +173,8 @@ dockers:
goarch: arm64
use: buildx
dockerfile: client/Dockerfile
extra_files:
- client/netbird-entrypoint.sh
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.created={{.Date}}"
@ -188,6 +192,8 @@ dockers:
goarm: 6
use: buildx
dockerfile: client/Dockerfile
extra_files:
- client/netbird-entrypoint.sh
build_flag_templates:
- "--platform=linux/arm"
- "--label=org.opencontainers.image.created={{.Date}}"
@ -205,6 +211,8 @@ dockers:
goarch: amd64
use: buildx
dockerfile: client/Dockerfile-rootless
extra_files:
- client/netbird-entrypoint.sh
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.created={{.Date}}"
@ -221,6 +229,8 @@ dockers:
goarch: arm64
use: buildx
dockerfile: client/Dockerfile-rootless
extra_files:
- client/netbird-entrypoint.sh
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.created={{.Date}}"
@ -238,6 +248,8 @@ dockers:
goarm: 6
use: buildx
dockerfile: client/Dockerfile-rootless
extra_files:
- client/netbird-entrypoint.sh
build_flag_templates:
- "--platform=linux/arm"
- "--label=org.opencontainers.image.created={{.Date}}"

View File

@ -1,9 +1,27 @@
FROM alpine:3.21.3
# build & run locally with:
# cd "$(git rev-parse --show-toplevel)"
# CGO_ENABLED=0 go build -o netbird ./client
# sudo podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# sudo podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
FROM alpine:3.22.0
# iproute2: busybox doesn't display ip rules properly
RUN apk add --no-cache ca-certificates ip6tables iproute2 iptables
RUN apk add --no-cache \
bash \
ca-certificates \
ip6tables \
iproute2 \
iptables
ENV \
NETBIRD_BIN="/usr/local/bin/netbird" \
NB_LOG_FILE="console,/var/log/netbird/client.log" \
NB_DAEMON_ADDR="unix:///var/run/netbird.sock" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
NB_ENTRYPOINT_LOGIN_TIMEOUT="1"
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
ARG NETBIRD_BINARY=netbird
COPY ${NETBIRD_BINARY} /usr/local/bin/netbird
ENV NB_FOREGROUND_MODE=true
ENTRYPOINT [ "/usr/local/bin/netbird","up"]
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird

View File

@ -1,18 +1,33 @@
FROM alpine:3.21.0
# build & run locally with:
# cd "$(git rev-parse --show-toplevel)"
# CGO_ENABLED=0 go build -o netbird ./client
# podman build -t localhost/netbird:latest -f client/Dockerfile --ignorefile .dockerignore-client .
# podman run --rm -it --cap-add={BPF,NET_ADMIN,NET_RAW} localhost/netbird:latest
ARG NETBIRD_BINARY=netbird
COPY ${NETBIRD_BINARY} /usr/local/bin/netbird
FROM alpine:3.22.0
RUN apk add --no-cache ca-certificates \
RUN apk add --no-cache \
bash \
ca-certificates \
&& adduser -D -h /var/lib/netbird netbird
WORKDIR /var/lib/netbird
USER netbird:netbird
ENV NB_FOREGROUND_MODE=true
ENV NB_USE_NETSTACK_MODE=true
ENV NB_ENABLE_NETSTACK_LOCAL_FORWARDING=true
ENV NB_CONFIG=config.json
ENV NB_DAEMON_ADDR=unix://netbird.sock
ENV NB_DISABLE_DNS=true
ENV \
NETBIRD_BIN="/usr/local/bin/netbird" \
NB_USE_NETSTACK_MODE="true" \
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
NB_CONFIG="/var/lib/netbird/config.json" \
NB_STATE_DIR="/var/lib/netbird" \
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
NB_DISABLE_DNS="true" \
NB_ENTRYPOINT_SERVICE_TIMEOUT="5" \
NB_ENTRYPOINT_LOGIN_TIMEOUT="1"
ENTRYPOINT [ "/usr/local/bin/netbird", "up" ]
ENTRYPOINT [ "/usr/local/bin/netbird-entrypoint.sh" ]
ARG NETBIRD_BINARY=netbird
COPY client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
COPY "${NETBIRD_BINARY}" /usr/local/bin/netbird

View File

@ -20,7 +20,7 @@ var downCmd = &cobra.Command{
cmd.SetOut(cmd.OutOrStdout())
err := util.InitLog(logLevel, "console")
err := util.InitLog(logLevel, util.LogConsole)
if err != nil {
log.Errorf("failed initializing log %v", err)
return err

View File

@ -32,7 +32,7 @@ var loginCmd = &cobra.Command{
cmd.SetOut(cmd.OutOrStdout())
err := util.InitLog(logLevel, "console")
err := util.InitLog(logLevel, util.LogConsole)
if err != nil {
return fmt.Errorf("failed initializing log %v", err)
}
@ -50,7 +50,7 @@ var loginCmd = &cobra.Command{
}
// workaround to run without service
if logFile == "console" {
if util.FindFirstLogPath(logFiles) == "" {
err = handleRebrand(cmd)
if err != nil {
return err

View File

@ -21,7 +21,7 @@ func TestLogin(t *testing.T) {
"--config",
confPath,
"--log-file",
"console",
util.LogConsole,
"--setup-key",
strings.ToUpper("a2c8e62b-38f5-4553-b31e-dd66c696cebb"),
"--management-url",

View File

@ -10,6 +10,7 @@ import (
"os/signal"
"path"
"runtime"
"slices"
"strings"
"syscall"
"time"
@ -51,7 +52,7 @@ var (
defaultLogFile string
oldDefaultLogFileDir string
oldDefaultLogFile string
logFile string
logFiles []string
daemonAddr string
managementURL string
adminURL string
@ -120,7 +121,7 @@ func init() {
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", internal.DefaultAdminURL))
rootCmd.PersistentFlags().StringVarP(&configPath, "config", "c", defaultConfigPath, "Netbird config file location")
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets Netbird log level")
rootCmd.PersistentFlags().StringVar(&logFile, "log-file", defaultLogFile, "sets Netbird log path. If console is specified the log will be output to stdout. If syslog is specified the log will be sent to syslog daemon.")
rootCmd.PersistentFlags().StringSliceVar(&logFiles, "log-file", []string{defaultLogFile}, "sets Netbird log paths written to simultaneously. If `console` is specified the log will be output to stdout. If `syslog` is specified the log will be sent to syslog daemon. You can pass the flag multiple times or separate entries by `,` character")
rootCmd.PersistentFlags().StringVarP(&setupKey, "setup-key", "k", "", "Setup key obtained from the Management Service Dashboard (used to register peer)")
rootCmd.PersistentFlags().StringVar(&setupKeyPath, "setup-key-file", "", "The path to a setup key obtained from the Management Service Dashboard (used to register peer) This is ignored if the setup-key flag is provided.")
rootCmd.MarkFlagsMutuallyExclusive("setup-key", "setup-key-file")
@ -265,7 +266,7 @@ func getSetupKeyFromFile(setupKeyPath string) (string, error) {
func handleRebrand(cmd *cobra.Command) error {
var err error
if logFile == defaultLogFile {
if slices.Contains(logFiles, defaultLogFile) {
if migrateToNetbird(oldDefaultLogFile, defaultLogFile) {
cmd.Printf("will copy Log dir %s and its content to %s\n", oldDefaultLogFileDir, defaultLogFileDir)
err = cpDir(oldDefaultLogFileDir, defaultLogFileDir)

View File

@ -61,7 +61,7 @@ func (p *program) Start(svc service.Service) error {
}
}
serverInstance := server.New(p.ctx, configPath, logFile)
serverInstance := server.New(p.ctx, configPath, util.FindFirstLogPath(logFiles))
if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err)
}
@ -112,7 +112,7 @@ func setupServiceControlCommand(cmd *cobra.Command, ctx context.Context, cancel
return nil, err
}
if err := util.InitLog(logLevel, logFile); err != nil {
if err := util.InitLog(logLevel, logFiles...); err != nil {
return nil, fmt.Errorf("init log: %w", err)
}
@ -136,7 +136,7 @@ var runCmd = &cobra.Command{
ctx, cancel := context.WithCancel(cmd.Context())
SetupCloseHandler(ctx, cancel)
SetupDebugHandler(ctx, nil, nil, nil, logFile)
SetupDebugHandler(ctx, nil, nil, nil, util.FindFirstLogPath(logFiles))
s, err := setupServiceControlCommand(cmd, ctx, cancel)
if err != nil {

View File

@ -12,6 +12,8 @@ import (
"github.com/kardianos/service"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/util"
)
var ErrGetServiceStatus = fmt.Errorf("failed to get service status")
@ -41,7 +43,7 @@ func buildServiceArguments() []string {
args = append(args, "--management-url", managementURL)
}
if logFile != "" {
for _, logFile := range logFiles {
args = append(args, "--log-file", logFile)
}
@ -54,7 +56,7 @@ func configurePlatformSpecificSettings(svcConfig *service.Config) error {
// Respected only by systemd systems
svcConfig.Dependencies = []string{"After=network.target syslog.target"}
if logFile != "console" {
if logFile := util.FindFirstLogPath(logFiles); logFile != "" {
setStdLogPath := true
dir := filepath.Dir(logFile)

View File

@ -46,7 +46,7 @@ var sshCmd = &cobra.Command{
cmd.SetOut(cmd.OutOrStdout())
err := util.InitLog(logLevel, "console")
err := util.InitLog(logLevel, util.LogConsole)
if err != nil {
return fmt.Errorf("failed initializing log %v", err)
}

View File

@ -59,7 +59,7 @@ func statusFunc(cmd *cobra.Command, args []string) error {
return err
}
err = util.InitLog(logLevel, "console")
err = util.InitLog(logLevel, util.LogConsole)
if err != nil {
return fmt.Errorf("failed initializing log %v", err)
}

View File

@ -79,7 +79,7 @@ func upFunc(cmd *cobra.Command, args []string) error {
cmd.SetOut(cmd.OutOrStdout())
err := util.InitLog(logLevel, "console")
err := util.InitLog(logLevel, util.LogConsole)
if err != nil {
return fmt.Errorf("failed initializing log %v", err)
}
@ -484,7 +484,7 @@ func parseCustomDNSAddress(modified bool) ([]byte, error) {
if !isValidAddrPort(customDNSAddress) {
return nil, fmt.Errorf("%s is invalid, it should be formatted as IP:Port string or as an empty string like \"\"", customDNSAddress)
}
if customDNSAddress == "" && logFile != "console" {
if customDNSAddress == "" && util.FindFirstLogPath(logFiles) != "" {
parsed = []byte("empty")
} else {
parsed = []byte(customDNSAddress)

View File

@ -17,7 +17,7 @@ import (
)
func TestMain(m *testing.M) {
_ = util.InitLog("trace", "console")
_ = util.InitLog("trace", util.LogConsole)
code := m.Run()
os.Exit(code)
}

View File

@ -16,6 +16,7 @@ import (
"path/filepath"
"runtime"
"runtime/pprof"
"slices"
"sort"
"strings"
"time"
@ -28,6 +29,7 @@ import (
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/statemanager"
mgmProto "github.com/netbirdio/netbird/management/proto"
"github.com/netbirdio/netbird/util"
)
const readmeContent = `Netbird debug bundle
@ -283,7 +285,7 @@ func (g *BundleGenerator) createArchive() error {
log.Errorf("Failed to add wg show output: %v", err)
}
if g.logFile != "console" && g.logFile != "" {
if g.logFile != "" && !slices.Contains(util.SpecialLogs, g.logFile) {
if err := g.addLogfile(); err != nil {
log.Errorf("Failed to add log file to debug bundle: %v", err)
if err := g.trySystemdLogFallback(); err != nil {

View File

@ -14,7 +14,7 @@ import (
)
func TestMain(m *testing.M) {
_ = util.InitLog("debug", "console")
_ = util.InitLog("debug", util.LogConsole)
code := m.Run()
os.Exit(code)
}

View File

@ -196,7 +196,7 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time {
}
func TestMain(m *testing.M) {
_ = util.InitLog("debug", "console")
_ = util.InitLog("debug", util.LogConsole)
code := m.Run()
os.Exit(code)
}

View File

@ -31,7 +31,7 @@ var connConf = ConnConfig{
}
func TestMain(m *testing.M) {
_ = util.InitLog("trace", "console")
_ = util.InitLog("trace", util.LogConsole)
code := m.Run()
os.Exit(code)
}

105
client/netbird-entrypoint.sh Executable file
View File

@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -eEuo pipefail
: ${NB_ENTRYPOINT_SERVICE_TIMEOUT:="5"}
: ${NB_ENTRYPOINT_LOGIN_TIMEOUT:="1"}
NETBIRD_BIN="${NETBIRD_BIN:-"netbird"}"
export NB_LOG_FILE="${NB_LOG_FILE:-"console,/var/log/netbird/client.log"}"
service_pids=()
log_file_path=""
_log() {
# mimic Go logger's output for easier parsing
# 2025-04-15T21:32:00+08:00 INFO client/internal/config.go:495: setting notifications to disabled by default
printf "$(date -Isec) ${1} ${BASH_SOURCE[1]}:${BASH_LINENO[1]}: ${2}\n" "${@:3}" >&2
}
info() {
_log INFO "$@"
}
warn() {
_log WARN "$@"
}
on_exit() {
info "Shutting down NetBird daemon..."
if test "${#service_pids[@]}" -gt 0; then
info "terminating service process IDs: ${service_pids[@]@Q}"
kill -TERM "${service_pids[@]}" 2>/dev/null || true
wait "${service_pids[@]}" 2>/dev/null || true
else
info "there are no service processes to terminate"
fi
}
wait_for_message() {
local timeout="${1}" message="${2}"
if test "${timeout}" -eq 0; then
info "not waiting for log line ${message@Q} due to zero timeout."
elif test -n "${log_file_path}"; then
info "waiting for log line ${message@Q} for ${timeout} seconds..."
grep -q "${message}" <(timeout "${timeout}" tail -F "${log_file_path}" 2>/dev/null)
else
info "log file unsupported, sleeping for ${timeout} seconds..."
sleep "${timeout}"
fi
}
locate_log_file() {
local log_files_string="${1}"
while read -r log_file; do
case "${log_file}" in
console | syslog) ;;
*)
log_file_path="${log_file}"
return
;;
esac
done < <(sed 's#,#\n#g' <<<"${log_files_string}")
warn "log files parsing for ${log_files_string@Q} is not supported by debug bundles"
warn "please consider removing the \$NB_LOG_FILE or setting it to real file, before gathering debug bundles."
}
wait_for_daemon_startup() {
local timeout="${1}"
if test -n "${log_file_path}"; then
if ! wait_for_message "${timeout}" "started daemon server"; then
warn "log line containing 'started daemon server' not found after ${timeout} seconds"
warn "daemon failed to start, exiting..."
exit 1
fi
else
warn "daemon service startup not discovered, sleeping ${timeout} instead"
sleep "${timeout}"
fi
}
login_if_needed() {
local timeout="${1}"
if test -n "${log_file_path}" && wait_for_message "${timeout}" 'peer has been successfully registered'; then
info "already logged in, skipping 'netbird up'..."
else
info "logging in..."
"${NETBIRD_BIN}" up
fi
}
main() {
trap 'on_exit' SIGTERM SIGINT EXIT
"${NETBIRD_BIN}" service run &
service_pids+=("$!")
info "registered new service process 'netbird service run', currently running: ${service_pids[@]@Q}"
locate_log_file "${NB_LOG_FILE}"
wait_for_daemon_startup "${NB_ENTRYPOINT_SERVICE_TIMEOUT}"
login_if_needed "${NB_ENTRYPOINT_LOGIN_TIMEOUT}"
wait "${service_pids[@]}"
}
main "$@"

View File

@ -32,6 +32,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/signal/proto"
signalServer "github.com/netbirdio/netbird/signal/server"
"github.com/netbirdio/netbird/util"
)
var (
@ -92,7 +93,7 @@ func TestConnectWithRetryRuns(t *testing.T) {
func TestServer_Up(t *testing.T) {
ctx := internal.CtxInitState(context.Background())
s := New(ctx, t.TempDir()+"/config.json", "console")
s := New(ctx, t.TempDir()+"/config.json", util.LogConsole)
err := s.Start()
require.NoError(t, err)
@ -130,7 +131,7 @@ func (m *mockSubscribeEventsServer) Context() context.Context {
func TestServer_SubcribeEvents(t *testing.T) {
ctx := internal.CtxInitState(context.Background())
s := New(ctx, t.TempDir()+"/config.json", "console")
s := New(ctx, t.TempDir()+"/config.json", util.LogConsole)
err := s.Start()
require.NoError(t, err)

View File

@ -66,7 +66,7 @@ func main() {
}
logFile = file
} else {
_ = util.InitLog("trace", "console")
_ = util.InitLog("trace", util.LogConsole)
}
// Create the Fyne application.

View File

@ -41,7 +41,7 @@ import (
const ValidKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
func TestMain(m *testing.M) {
_ = util.InitLog("debug", "console")
_ = util.InitLog("debug", util.LogConsole)
code := m.Run()
os.Exit(code)
}

View File

@ -30,7 +30,7 @@ var (
)
func TestMain(m *testing.M) {
_ = util.InitLog("debug", "console")
_ = util.InitLog("debug", util.LogConsole)
code := m.Run()
os.Exit(code)
}

View File

@ -73,7 +73,7 @@ var (
)
func init() {
_ = util.InitLog("trace", "console")
_ = util.InitLog("trace", util.LogConsole)
cobraConfig = &Config{}
rootCmd.PersistentFlags().StringVarP(&cobraConfig.ListenAddress, "listen-address", "l", ":443", "listen address")
rootCmd.PersistentFlags().StringVarP(&cobraConfig.ExposedAddress, "exposed-address", "e", "", "instance domain address (or ip) and port, it will be distributes between peers")

View File

@ -27,7 +27,7 @@ var (
)
func TestMain(m *testing.M) {
_ = util.InitLog("error", "console")
_ = util.InitLog("error", util.LogConsole)
code := m.Run()
os.Exit(code)
}

View File

@ -233,7 +233,7 @@ func TURNReaderMain() []testResult {
func main() {
var mode string
_ = util.InitLog("debug", "console")
_ = util.InitLog("debug", util.LogConsole)
flag.StringVar(&mode, "mode", "sender", "sender or receiver mode")
flag.Parse()

View File

@ -10,7 +10,7 @@ import (
)
func main() {
err := util.InitLog("info", "console")
err := util.InitLog("info", util.LogConsole)
if err != nil {
log.Fatalf("Failed to initialize logger: %v", err)
}

View File

@ -16,36 +16,54 @@ import (
const defaultLogSize = 15
const (
LogConsole = "console"
LogSyslog = "syslog"
)
var (
SpecialLogs = []string{
LogSyslog,
LogConsole,
}
)
// InitLog parses and sets log-level input
func InitLog(logLevel string, logPath string) error {
func InitLog(logLevel string, logs ...string) error {
level, err := log.ParseLevel(logLevel)
if err != nil {
log.Errorf("Failed parsing log-level %s: %s", logLevel, err)
return err
}
customOutputs := []string{"console", "syslog"}
var writers []io.Writer
logFmt := os.Getenv("NB_LOG_FORMAT")
if logPath != "" && !slices.Contains(customOutputs, logPath) {
maxLogSize := getLogMaxSize()
lumberjackLogger := &lumberjack.Logger{
// Log file absolute path, os agnostic
Filename: filepath.ToSlash(logPath),
MaxSize: maxLogSize, // MB
MaxBackups: 10,
MaxAge: 30, // days
Compress: true,
for _, logPath := range logs {
switch logPath {
case LogSyslog:
AddSyslogHook()
logFmt = "syslog"
case LogConsole:
writers = append(writers, os.Stderr)
case "":
log.Warnf("empty log path received: %#v", logPath)
default:
writers = append(writers, newRotatedOutput(logPath))
}
log.SetOutput(io.Writer(lumberjackLogger))
} else if logPath == "syslog" {
AddSyslogHook()
}
//nolint:gocritic
if os.Getenv("NB_LOG_FORMAT") == "json" {
if len(writers) > 1 {
log.SetOutput(io.MultiWriter(writers...))
} else if len(writers) == 1 {
log.SetOutput(writers[0])
}
switch logFmt {
case "json":
formatter.SetJSONFormatter(log.StandardLogger())
} else if logPath == "syslog" {
case "syslog":
formatter.SetSyslogFormatter(log.StandardLogger())
} else {
default:
formatter.SetTextFormatter(log.StandardLogger())
}
log.SetLevel(level)
@ -55,6 +73,29 @@ func InitLog(logLevel string, logPath string) error {
return nil
}
// FindFirstLogPath returns the first logs entry that could be a log path, that is neither empty, nor a special value
func FindFirstLogPath(logs []string) string {
for _, logFile := range logs {
if logFile != "" && !slices.Contains(SpecialLogs, logFile) {
return logFile
}
}
return ""
}
func newRotatedOutput(logPath string) io.Writer {
maxLogSize := getLogMaxSize()
lumberjackLogger := &lumberjack.Logger{
// Log file absolute path, os agnostic
Filename: filepath.ToSlash(logPath),
MaxSize: maxLogSize, // MB
MaxBackups: 10,
MaxAge: 30, // days
Compress: true,
}
return lumberjackLogger
}
func setGRPCLibLogger() {
logOut := log.StandardLogger().Writer()
if os.Getenv("GRPC_GO_LOG_SEVERITY_LEVEL") != "info" {