Skip to content
Merged
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
35 changes: 35 additions & 0 deletions agent/backend/devicediscovery/device_discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type deviceDiscoveryBackend struct {
diodeTargetFromOtel bool
diodeDryRun bool
diodeDryRunOutputDir string
diodeLogLevel string

startTime time.Time
proc backend.Commander
Expand Down Expand Up @@ -88,6 +89,34 @@ func (d *deviceDiscoveryBackend) Configure(logger *slog.Logger, repo policies.Po
d.diodeDryRun = backend.ConfigValueOrDefault(config, "dry_run", common.Diode.DryRun)
d.diodeDryRunOutputDir = backend.ConfigValueOrDefault(config, "dry_run_output_dir", common.Diode.DryRunOutputDir)

// Precedence: explicit log_level > per-backend debug: true > the agent
// itself running in debug mode. A non-string log_level (YAML
// `log_level: 3`) falls through the type assertion and cannot panic.
//
// The third branch deliberately reads common.Debug rather than
// logger.Enabled(ctx, slog.LevelDebug), which is what the other three
// discovery backends currently do. logger.Enabled is not a reliable proxy
// for debug mode here: when orb.backends.common.otlp.grpc is set,
// agent.go:165-166 replaces the agent logger with a telemetry.multiHandler
// wrapping both the console handler and the otelslog handler. multiHandler
// ORs Enabled across its handlers (telemetry/logs.go:52-59) and the
// otelslog handler applies no level filter, so Enabled(Debug) is true
// whenever OTLP log export is enabled -- with or without -d. That would
// silently start device-discovery at DEBUG and export the full traceback
// plus napalm/netmiko/paramiko/ncclient chatter to the collector.
//
// common.Debug is the explicit state: agent.go:160 sets it from a.debug,
// which cmd/main.go:95 derives as debugFlag || cfg.OrbAgent.Debug.Enable.
// The same fix is owed to networkdiscovery, snmpdiscovery and
// gnmidiscovery; tracked separately.
if logLevel, prs := config["log_level"].(string); prs {
d.diodeLogLevel = logLevel
} else if debug, prs := config["debug"].(bool); prs && debug {
d.diodeLogLevel = "debug"
} else if common.Debug {
d.diodeLogLevel = "debug"
}

if common.Otlp.Grpc != "" {
d.diodeOtelEndpoint = common.Otlp.Grpc
d.logger.Info("device-discovery using OTLP endpoint",
Expand Down Expand Up @@ -136,6 +165,12 @@ func (d *deviceDiscoveryBackend) buildArgs() []string {
dOptions = append(opts, dOptions...)
}

if d.diodeLogLevel != "" {
dOptions = append(dOptions, "--log-level", d.diodeLogLevel)
d.logger.Info("device-discovery using log level",
"log_level", d.diodeLogLevel)
}

if d.diodeOtelEndpoint != "" {
dOptions = append(dOptions, "--otel-endpoint", d.diodeOtelEndpoint)
}
Expand Down
3 changes: 3 additions & 0 deletions agent/backend/devicediscovery/device_discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ func TestDeviceDiscoveryBackendStart(t *testing.T) {
assert.Contains(t, args, "device-secret", "Expected args to contain diode client secret")
assert.Contains(t, args, "--diode-app-name-prefix", "Expected args to contain diode app name prefix flag")
assert.Contains(t, args, "device-agent", "Expected args to contain diode app name prefix")
assert.Contains(t, args, "--log-level", "Expected args to contain log level flag")
assert.Contains(t, args, "debug", "Expected args to contain log level value")
assert.Contains(t, args, "--otel-endpoint", "Expected args to contain otel endpoint flag")
assert.Contains(t, args, "collector:4317", "Expected args to contain otel endpoint value")
})
Expand Down Expand Up @@ -116,6 +118,7 @@ func TestDeviceDiscoveryBackendStart(t *testing.T) {
"agent_name": "device-agent",
"dry_run": false,
"dry_run_output_dir": "/tmp/device",
"log_level": "debug",
}, commons, nil)
require.NoError(t, err)

Expand Down
196 changes: 196 additions & 0 deletions agent/backend/devicediscovery/log_level_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package devicediscovery

import (
"context"
"io"
"log/slog"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/netboxlabs/orb-agent/agent/config"
)

// discardLogger is a plain Info-level logger; the level is irrelevant to the
// precedence chain now that it keys off common.Debug rather than the handler.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelInfo}))
}

// otlpLikeHandler mimics the otelslog handler that
// telemetry.BuildOTLPLogExporter merges into the agent logger: no level filter,
// so Enabled always reports true. telemetry.multiHandler ORs Enabled across its
// handlers, which is why an OTLP-wrapped agent logger claims Debug is enabled
// even when the agent was never started with -d.
type otlpLikeHandler struct {
slog.Handler
}

func (otlpLikeHandler) Enabled(context.Context, slog.Level) bool { return true }

func otlpWrappedLogger() *slog.Logger {
return slog.New(otlpLikeHandler{Handler: slog.NewTextHandler(io.Discard, nil)})
}

// flagValue returns the argument immediately following flag. Asserting on
// adjacency is strictly stronger than two separate assert.Contains calls, which
// would pass even if the flag and its value were separated by another option.
func flagValue(t *testing.T, args []string, flag string) (string, bool) {
t.Helper()
for i, arg := range args {
if arg == flag {
require.Less(t, i+1, len(args), "flag %s has no value following it", flag)
return args[i+1], true
}
}
return "", false
}

func TestConfigureLogLevelPrecedence(t *testing.T) {
tests := []struct {
name string
config map[string]any
agentDebug bool
expected string
}{
{
name: "explicit log_level wins",
config: map[string]any{"log_level": "error"},
expected: "error",
},
{
name: "explicit log_level beats per-backend debug",
config: map[string]any{"log_level": "error", "debug": true},
expected: "error",
},
{
name: "explicit log_level beats agent debug",
config: map[string]any{"log_level": "error"},
agentDebug: true,
expected: "error",
},
{
name: "per-backend debug implies debug",
config: map[string]any{"debug": true},
expected: "debug",
},
{
name: "per-backend debug false does not imply debug",
config: map[string]any{"debug": false},
expected: "",
},
{
name: "agent debug implies debug",
config: map[string]any{},
agentDebug: true,
expected: "debug",
},
{
name: "nothing set leaves it empty",
config: map[string]any{},
expected: "",
},
{
// YAML `log_level: 3` must fall through the type assertion.
name: "non-string log_level does not panic",
config: map[string]any{"log_level": 3},
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
commons := config.BackendCommons{}
commons.Debug = tt.agentDebug

d := &deviceDiscoveryBackend{apiProtocol: "http"}
require.NotPanics(t, func() {
require.NoError(t, d.Configure(discardLogger(), nil, tt.config, commons, nil))
})
assert.Equal(t, tt.expected, d.diodeLogLevel)
})
}
}

// TestConfigureDoesNotTreatOtlpExportAsDebug is the regression guard for the
// review finding on PR #510. Enabling OTLP log export must not, by itself, put
// the backend into debug.
func TestConfigureDoesNotTreatOtlpExportAsDebug(t *testing.T) {
logger := otlpWrappedLogger()
require.True(t, logger.Enabled(context.Background(), slog.LevelDebug),
"precondition: an OTLP-wrapped agent logger reports Debug as enabled")

commons := config.BackendCommons{}
commons.Otlp.Grpc = "collector:4317"
commons.Debug = false // no -d, no orb.debug.enable

d := &deviceDiscoveryBackend{apiProtocol: "http"}
require.NoError(t, d.Configure(logger, nil, map[string]any{}, commons, nil))

assert.Empty(t, d.diodeLogLevel,
"OTLP log export alone must not enable debug — see telemetry/logs.go:52-59")

_, found := flagValue(t, d.buildArgs(), "--log-level")
assert.False(t, found, "expected no --log-level when only OTLP export is configured")
}

// TestConfigureOtlpPlusAgentDebugStillEnablesDebug is the other half: the fix
// must not have broken the legitimate case.
func TestConfigureOtlpPlusAgentDebugStillEnablesDebug(t *testing.T) {
commons := config.BackendCommons{}
commons.Otlp.Grpc = "collector:4317"
commons.Debug = true

d := &deviceDiscoveryBackend{apiProtocol: "http"}
require.NoError(t, d.Configure(otlpWrappedLogger(), nil, map[string]any{}, commons, nil))

assert.Equal(t, "debug", d.diodeLogLevel)
}

func TestBuildArgsIncludesLogLevel(t *testing.T) {
d := &deviceDiscoveryBackend{apiProtocol: "http"}
require.NoError(t, d.Configure(discardLogger(), nil,
map[string]any{"log_level": "debug"}, config.BackendCommons{}, nil))

args := d.buildArgs()

value, found := flagValue(t, args, "--log-level")
require.True(t, found, "expected --log-level in %v", args)
assert.Equal(t, "debug", value, "--log-level must be immediately followed by its value")
}

func TestBuildArgsOmitsLogLevelWhenUnset(t *testing.T) {
// Pins the byte-identical default command line: with no log_level and no
// debug anywhere, the arguments must be exactly what they were before this
// change. That is what bounds the blast radius.
d := &deviceDiscoveryBackend{apiProtocol: "http"}
require.NoError(t, d.Configure(discardLogger(), nil,
map[string]any{}, config.BackendCommons{}, nil))

args := d.buildArgs()

_, found := flagValue(t, args, "--log-level")
assert.False(t, found, "expected no --log-level in %v", args)
}

func TestBuildArgsLogLevelPrecedesOtelEndpoint(t *testing.T) {
// Order matters only in that both must survive; this catches an append that
// accidentally replaces rather than extends.
commons := config.BackendCommons{}
commons.Otlp.Grpc = "collector:4317"

d := &deviceDiscoveryBackend{apiProtocol: "http"}
require.NoError(t, d.Configure(discardLogger(), nil,
map[string]any{"log_level": "warn"}, commons, nil))

args := d.buildArgs()

logLevel, foundLevel := flagValue(t, args, "--log-level")
require.True(t, foundLevel)
assert.Equal(t, "warn", logLevel)

endpoint, foundEndpoint := flagValue(t, args, "--otel-endpoint")
require.True(t, foundEndpoint)
assert.Equal(t, "collector:4317", endpoint)
}
31 changes: 31 additions & 0 deletions agent/backend/devicediscovery/normalize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseDeviceDiscoveryLevel(t *testing.T) {
Expand Down Expand Up @@ -40,6 +41,36 @@ func TestParseDeviceDiscoveryLevel(t *testing.T) {
}
}

func TestNormalizeDeviceDiscoveryLine_ExpectedTargetFailureWarning(t *testing.T) {
// The verbatim line the Python backend now emits for an unreachable target
// (device_discovery/policy/runner.py, _log_target_failure). If this stops
// parsing, the agent silently falls back to assigning level by pipe and the
// #494 fix regresses from the operator's point of view.
line := "WARNING:device_discovery.policy.runner:Policy lab_mgmt_device_policy, " +
"Hostname 10.0.0.5: Cannot connect to 10.0.0.5"

msg, attrs, level, ok := normalizeDeviceDiscoveryLine(line, slog.LevelError)

assert.True(t, ok)
assert.Equal(t, slog.LevelWarn, level)
require.Len(t, attrs, 1)
assert.Equal(t, "module", attrs[0].Key)
assert.Equal(t, "device_discovery.policy.runner", attrs[0].Value.String())
assert.Contains(t, msg, "Cannot connect to 10.0.0.5")
}

func TestNormalizeDeviceDiscoveryLine_ContinuationStillFallsBackToStderrLevel(t *testing.T) {
// Known remaining behaviour, recorded as a test rather than left implicit.
// A traceback continuation line has no LEVEL: prefix, so it keeps the
// caller's fallback -- which logLineAdapter sets to ERROR for stderr. The
// generic per-line stderr amplifier is deliberately out of scope for #494;
// the Python side avoids it by emitting exactly one physical line.
_, _, level, ok := normalizeDeviceDiscoveryLine(" self._open()", slog.LevelError)

assert.False(t, ok)
assert.Equal(t, slog.LevelError, level)
}

func TestNormalizeDeviceDiscoveryLine_Valid(t *testing.T) {
msg, attrs, level, ok := normalizeDeviceDiscoveryLine("INFO: mymodule: hello world", slog.LevelDebug)
assert.True(t, ok)
Expand Down
22 changes: 16 additions & 6 deletions agent/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,6 @@ FROM netboxlabs/pktvisor:${PKTVISOR_TAG} AS pktvisor
########################################################################
FROM python:3.14-alpine AS python-builder

# Upgrade pip to latest version with fewer vulnerabilities
RUN python -m pip install --upgrade --no-cache-dir pip==26.1.2 && \
rm -rf /usr/local/lib/python3.14/ensurepip/_bundled/pip-*.whl

# Install the Python backends from in-repo source (was: published PyPI packages
# netboxlabs-device-discovery / netboxlabs-orb-worker). Console scripts
# device-discovery and orb-worker land in /usr/local/bin.
Expand All @@ -109,7 +105,13 @@ RUN BUILD_COMMIT="${BUILD_COMMIT}" BUILD_TRACK="${BUILD_TRACK}" \
python /tmp/stamp_version.py "${DEVICE_DISCOVERY_VERSION}" /src/device-discovery/device_discovery/version.py && \
BUILD_COMMIT="${BUILD_COMMIT}" BUILD_TRACK="${BUILD_TRACK}" \
python /tmp/stamp_version.py "${WORKER_VERSION}" /src/worker/worker/version.py
RUN pip3 install --no-cache-dir /src/device-discovery /src/worker
# Uninstall pip once the packages are installed so the site-packages COPY into
# the final stage carries application code only. Copying pip across stages
# merges it file-by-file over the final stage's own pip — two versions with
# different module layouts produce a broken chimera install (pip3 dies with
# ImportError on every invocation).
RUN pip3 install --no-cache-dir /src/device-discovery /src/worker && \
pip3 uninstall -y pip

########################################################################
# Stage name is referenced by no-cache-filters in the build workflows so
Expand Down Expand Up @@ -141,7 +143,15 @@ ENV PATH=/opt/orb/files:$PATH
ENV PIP_CACHE_DIR=/opt/orb/pip-cache
ENV DEFAULT_CONFIG_PATH=/usr/local/share/orb-agent/default_config.yaml

# Copy Python site-packages and binaries from builder (includes pip and package executables)
# The runtime pip: used by the entrypoint's INSTALL_DRIVERS_PATH feature and
# what image scans see. Pinned to a recent release with fewer vulnerabilities;
# pip's self-upgrade cleanly removes the base image's copy first.
RUN python -m pip install --upgrade --no-cache-dir pip==26.2.1 && \
rm -rf /usr/local/lib/python3.14/ensurepip/_bundled/pip-*.whl

# Copy Python site-packages and binaries from builder (application packages and
# their console scripts; the builder uninstalled pip so it cannot leak into —
# and version-clash with — this stage's pip above)
COPY --from=python-builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=python-builder /usr/local/bin /usr/local/bin

Expand Down
Loading
Loading