diff --git a/Dockerfile b/Dockerfile index cdfed35..03f8004 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,18 @@ ARG TARGETOS TARGETARCH RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/proxy-sampler ./cmd/app FROM debian:bookworm-slim +# The nonroot account matches the numeric USER below. Without it the runtime UID +# resolves to no account at all, which breaks every os/user lookup in the +# process -- including the OpenTelemetry process.owner resource detector. RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 65532 nonroot \ + && useradd --system --uid 65532 --gid 65532 \ + --home-dir /home/nonroot --create-home --shell /usr/sbin/nologin nonroot COPY --from=go-builder /out/proxy-sampler /usr/local/bin/proxy-sampler ENV HTTP_ADDR=:8080 EXPOSE 8080 +# Numeric so Kubernetes runAsNonRoot can verify it without resolving the name. USER 65532:65532 ENTRYPOINT ["/usr/local/bin/proxy-sampler"] diff --git a/internal/telemetry/otel.go b/internal/telemetry/otel.go index 992574e..19b11e4 100644 --- a/internal/telemetry/otel.go +++ b/internal/telemetry/otel.go @@ -43,6 +43,7 @@ var ( newLogExporter = func(ctx context.Context) (sdklog.Exporter, error) { return otlploghttp.New(ctx) } + newResource = buildResource ) // Init installs OTLP/HTTP trace, metric, and log providers. With no common @@ -58,9 +59,15 @@ func Init(ctx context.Context, getenv func(string) string, logger *slog.Logger) logger = slog.Default() } - res, err := buildResource(ctx) + // resource.New reports every detector failure but still returns the + // attributes the remaining detectors produced, so a failure here degrades + // the resource rather than invalidating it. Note that not all of these + // errors wrap resource.ErrPartialResource: the process owner detector + // returns user.Current's error verbatim, which is how a container UID + // without an /etc/passwd entry surfaces. + res, err := newResource(ctx) if err != nil { - return nil, fmt.Errorf("build telemetry resource: %w", err) + logger.Warn("incomplete telemetry resource", "error", err) } metricExporter, err := newMetricExporter(ctx) if err != nil { diff --git a/internal/telemetry/otel_test.go b/internal/telemetry/otel_test.go index 51c4736..ce9e9a7 100644 --- a/internal/telemetry/otel_test.go +++ b/internal/telemetry/otel_test.go @@ -17,6 +17,7 @@ import ( sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" ) @@ -123,6 +124,51 @@ func TestInitCleansMetricExporterWhenTraceSetupFails(t *testing.T) { } } +func TestInitKeepsPartialResourceWhenDetectionFails(t *testing.T) { + // resource.New reports detector failures but still returns everything the + // remaining detectors produced. The process owner detector returns + // user.Current's error verbatim rather than wrapping ErrPartialResource, so + // a container UID with no /etc/passwd entry yields a plain error here. This + // is the wording the CGO_ENABLED=0 build emits; cgo builds instead report + // "user: unknown userid 65532". + detectionErr := errors.New("error detecting resource: user: Current requires cgo or $USER set in environment") + partial := resource.NewSchemaless(attribute.String("service.name", "proxy-sampler")) + restoreResource := replaceResourceFactory(t, func(context.Context) (*resource.Resource, error) { + return partial, detectionErr + }) + defer restoreResource() + restore := replaceExporterFactories(t, + func(context.Context) (sdkmetric.Exporter, error) { return &fakeMetricExporter{}, nil }, + func(context.Context) (sdktrace.SpanExporter, error) { return &fakeTraceExporter{}, nil }, + func(context.Context) (sdklog.Exporter, error) { return &fakeLogExporter{}, nil }, + ) + defer restore() + + originalTracer := otel.GetTracerProvider() + originalMeter := otel.GetMeterProvider() + originalLogger := global.GetLoggerProvider() + t.Cleanup(func() { + otel.SetTracerProvider(originalTracer) + otel.SetMeterProvider(originalMeter) + global.SetLoggerProvider(originalLogger) + }) + + var logs bytes.Buffer + shutdown, err := Init(context.Background(), endpointGetenv, slog.New(slog.NewJSONHandler(&logs, nil))) + if err != nil { + t.Fatalf("Init with an incomplete resource: %v", err) + } + if otel.GetTracerProvider() == originalTracer || otel.GetMeterProvider() == originalMeter { + t.Fatal("incomplete resource detection suppressed the trace and metric providers") + } + if !strings.Contains(logs.String(), "Current requires cgo") { + t.Fatalf("incomplete resource detection was not reported: %s", logs.String()) + } + if err := shutdown(context.Background()); err != nil { + t.Fatalf("shutdown: %v", err) + } +} + func TestBuildResourceForcesProxySamplerServiceName(t *testing.T) { t.Setenv("OTEL_SERVICE_NAME", "wrong-service") res, err := buildResource(context.Background()) @@ -144,6 +190,13 @@ func endpointGetenv(name string) string { type exporterFactoryRestore func() +func replaceResourceFactory(t *testing.T, factory func(context.Context) (*resource.Resource, error)) exporterFactoryRestore { + t.Helper() + original := newResource + newResource = factory + return func() { newResource = original } +} + func replaceExporterFactories( t *testing.T, metricFactory func(context.Context) (sdkmetric.Exporter, error),