package helmexec import ( "context" "io" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace/noop" commonpb "go.opentelemetry.io/proto/otlp/common/v1" v1 "go.opentelemetry.io/proto/otlp/trace/v1" "github.com/helmfile/helmfile/pkg/telemetry" "github.com/helmfile/helmfile/pkg/telemetry/otlptest" ) // TestShellRunnerExecSpansExported drives ShellRunner.Execute through a real // OTLP export and asserts names, attributes, error status, and parent linkage // to the command span (i.e. the runner spans nest, never orphan). func TestShellRunnerExecSpansExported(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses the unix true/false binaries") } rec := otlptest.NewRecorder(t) otlptest.SetupTelemetry(t, rec, "helmfile test") shell := ShellRunner{ Logger: NewLogger(io.Discard, "warn"), Ctx: telemetry.CommandContext(), } out, err := shell.Execute("true", nil, nil, false) require.NoError(t, err) assert.Empty(t, out) // A failing command must surface its exit code and error status. _, err = shell.Execute("false", nil, nil, false) var exitErr ExitError require.ErrorAs(t, err, &exitErr) // Secret-bearing arguments must be redacted on the span. _, err = shell.Execute("true", []string{"--set", "secret=1", "--set=also=2"}, nil, false) require.NoError(t, err) otlptest.ShutdownTelemetry(t) spans := rec.Spans(t) root := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "helmfile test" }, "command span") success := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { cmd, ok := otlptest.AttrString(s, "exec.command") return s.Name == "os.exec" && ok && cmd == "true" && !otlptest.HasAttr(s, "exec.redacted") }, "success os.exec span") if success.Status != nil { assert.Equal(t, v1.Status_STATUS_CODE_UNSET, success.Status.Code, "success span must not be marked error") } failure := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { cmd, ok := otlptest.AttrString(s, "exec.command") return ok && cmd == "false" }, "failure os.exec span") require.NotNil(t, failure.Status) assert.Equal(t, v1.Status_STATUS_CODE_ERROR, failure.Status.Code) assert.EqualValues(t, 1, spanAttr(t, failure, "exec.exit_code").Value.GetIntValue()) redactedSpan := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return otlptest.HasAttr(s, "exec.redacted") }, "redacted os.exec span") assert.Equal(t, []string{"--set", redactedArg, "--set=" + redactedArg}, spanAttrStrings(t, redactedSpan, "exec.args")) // Every os.exec span must be a child of the command span, proving the // runner-span nesting (no orphan traces). for _, s := range spans { if s.Name == "os.exec" { assert.Equal(t, root.TraceId, s.TraceId, "os.exec span must join the command trace") assert.Equal(t, root.SpanId, s.ParentSpanId, "os.exec span must nest under the command span") } } } func spanAttr(t *testing.T, span *v1.Span, key string) *commonpb.KeyValue { t.Helper() for _, kv := range span.Attributes { if kv.Key == key { return kv } } t.Fatalf("attribute %q not found on span %q", key, span.Name) return nil } func spanAttrStrings(t *testing.T, span *v1.Span, key string) []string { t.Helper() values := spanAttr(t, span, key).Value.GetArrayValue().GetValues() out := make([]string, 0, len(values)) for _, v := range values { out = append(out, v.GetStringValue()) } return out } // TestSpanAttachedContext pins the per-call context merge that lets helm // subprocess spans nest under per-release spans WITHOUT overriding the // runner's own cancellation context (the kubedog safety valve). func TestSpanAttachedContext(t *testing.T) { runnerCtx, valveCancel := context.WithCancel(context.Background()) defer valveCancel() spanCtx, span := noop.NewTracerProvider().Tracer("test").Start(context.Background(), "release") merged := spanAttachedContext(runnerCtx, spanCtx) // The merged context still cancels with the runner's context. assert.Equal(t, runnerCtx.Done(), merged.Done(), "cancellation authority must stay with the runner context") // ...and carries the caller's span for nesting. assert.Equal(t, span, trace.SpanFromContext(merged), "the caller's span must be attached") // A typed nil runner context (distinct from the nil literal, per // staticcheck) falls back to the span context. var nilRunnerCtx context.Context assert.Equal(t, spanCtx, spanAttachedContext(nilRunnerCtx, spanCtx)) } // TestMarkHelmRunnerCoversBothFunnels pins that the shared marker helper — // used by BOTH execWithRunner and execStdIn — stamps value and pointer // runners alike, so wrapper binaries stay classified as helm operations on // stdin-based invocations (registry login, repo add) too. func TestMarkHelmRunnerCoversBothFunnels(t *testing.T) { ptr := &ShellRunner{} if v, ok := markHelmRunner(ptr).(*ShellRunner).Ctx.Value(helmExecMarker{}).(bool); !ok || !v { t.Error("pointer runner must carry the helm marker") } val := ShellRunner{} markedValue, ok := markHelmRunner(val).(ShellRunner) if !ok { t.Fatal("value runner must stay a value runner") } if v, ok := markedValue.Ctx.Value(helmExecMarker{}).(bool); !ok || !v { t.Error("value runner must carry the helm marker") } fake := &mockRunner{} assert.Same(t, fake, markHelmRunner(fake), "non-ShellRunner runners pass through unchanged") } // TestValueRunnerClassification pins that ShellRunner values (which satisfy // Runner via value receivers) receive the helm marker and span attachment // exactly like pointer runners. func TestValueRunnerClassification(t *testing.T) { spanCtx, span := noop.NewTracerProvider().Tracer("test").Start(context.Background(), "release") // marker stamping valueRunner := ShellRunner{} marked := markHelmExec(valueRunner.Ctx) if v, ok := marked.Value(helmExecMarker{}).(bool); !ok || !v { t.Fatal("value runner ctx must carry the helm marker") } // span attachment via the same path execWithContext uses for values merged := spanAttachedContext(valueRunner.Ctx, spanCtx) assert.Equal(t, span, trace.SpanFromContext(merged)) }