Files
helmfile/pkg/state/span.go
T
vulragrag-starandyxxhero d4a4135f9b fix: cancel kubedog-tracked helm subprocesses on SIGINT/SIGTERM (#2791)
* fix: cancel kubedog-tracked helm subprocesses on SIGINT/SIGTERM

Kubedog tracking rooted helm and tracker contexts at Background (via
traceOnlyContext), so App.Cancel never reached those subprocesses and
Ctrl+C blocked in CleanWaitGroup until helm exited on its own.

Thread the app cancel context into HelmState (SetCancelContext) and use
it for the three kubedog tracking call sites, keeping the per-release
WithCancel safety valve. Hooks stay on the non-canceling trace bridge.

Fixes #2770

Signed-off-by: Jason Wang <vulragrag@gmail.com>

* docs/test: align cancel-context docs with #2791 and pin the kubedog wiring

- traceOnlyContext comment no longer lists kubedog tracking among the
  detached paths; only hooks (#2771) remain, with a pointer to #2791.
- docs/proposals/otel-tracing.md sections 4.3/4.4/7/9 now mark the
  kubedog cancellation gap as fixed by #2791 (SetCancelContext), so the
  design doc stops misdescribing main.
- TestBufferHelmOutputRootsAtCancelContext pins the actual #2770 wiring:
  the context bufferHelmOutput hands to ContextSwapper.WithContext must
  become Done when the app cancel context is canceled, and stay
  non-cancelable when unset.

Signed-off-by: yxxhero <aiopsclub@163.com>

---------

Signed-off-by: Jason Wang <vulragrag@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-15 07:35:09 +08:00

152 lines
5.7 KiB
Go

package state
import (
gocontext "context"
"sort"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/telemetry"
)
// traceOnlyContext returns a context carrying trace context — the command
// span, or the given parent (typically a per-release span) — while never
// propagating cancellation. Hook execution must keep its detached
// cancellation semantics (#2771), so only trace context is bridged (see
// docs/proposals/otel-tracing.md §4.4). Kubedog tracking formerly rooted
// here too; it now derives from the app cancel context so SIGINT reaches
// the buffered helm subprocesses (#2770, #2791). With tracing disabled it
// is indistinguishable from Background.
func traceOnlyContext(parent ...gocontext.Context) gocontext.Context {
ctx := telemetry.CommandContext()
if len(parent) > 0 && parent[0] != nil {
ctx = parent[0]
}
return gocontext.WithoutCancel(ctx)
}
// SetTraceContext sets the context used as the parent of per-release spans
// (pkg/app sets it to the helmfile.load span context right after loading a
// state file). A nil context keeps spans parented at Background — with
// tracing disabled, span starts are no-ops anyway.
func (st *HelmState) SetTraceContext(ctx gocontext.Context) {
st.traceCtx = ctx
}
// SetCancelContext sets the context canceled with the app on SIGINT/SIGTERM.
// Kubedog tracking (and the helm subprocesses it buffers) derive from this so
// process-wide cancellation reaches them. A nil context keeps the historical
// Background-rooted behavior.
func (st *HelmState) SetCancelContext(ctx gocontext.Context) {
st.cancelCtx = ctx
}
func (st *HelmState) releaseSpanParent() gocontext.Context {
if st.traceCtx != nil {
return st.traceCtx
}
return gocontext.Background()
}
// releaseCancelContext returns the context kubedog tracking should root under.
// When unset it falls back to Background, matching the pre-#2770 detached
// semantics used by tests that construct HelmState literals without an App.
func (st *HelmState) releaseCancelContext() gocontext.Context {
if st.cancelCtx != nil {
return st.cancelCtx
}
return gocontext.Background()
}
// startReleaseSpan starts one helmfile.release.<verb> span for a release,
// parented from the state's trace context. The returned context is meant to
// be stamped into the per-release helmexec.HelmContext so the release's helm
// subprocesses nest under the span.
func (st *HelmState) startReleaseSpan(verb string, release *ReleaseSpec) (gocontext.Context, trace.Span) {
attrs := []attribute.KeyValue{
attribute.String("helmfile.release", release.Name),
attribute.String("helmfile.namespace", release.Namespace),
attribute.String("helmfile.chart", helmexec.RedactedRef(release.Chart)),
}
if release.Version != "" {
attrs = append(attrs, attribute.String("helmfile.chart_version", release.Version))
}
if len(release.Labels) > 0 {
attrs = append(attrs, attribute.StringSlice("helmfile.labels", sortedLabelPairs(release.Labels)))
}
ctx, span := telemetry.Tracer(telemetry.ScopeHelmfile).Start(st.releaseSpanParent(), "helmfile.release."+verb,
trace.WithAttributes(attrs...),
)
return ctx, span
}
// endReleaseSpan ends a release span, recording err (when non-nil) as the
// span's error status, and records the outcome on the helmfile.release.count
// and helmfile.release.duration metrics. Ending an already-ended span is a
// no-op, so it is safe to call from every exit path of a worker-loop item.
func endReleaseSpan(span trace.Span, verb string, release *ReleaseSpec, start time.Time, err error) {
if span == nil {
return
}
telemetry.RecordReleaseResult(verb, err)
telemetry.RecordReleaseDuration(time.Since(start).Seconds(), verb, err, release.Name, release.Namespace)
if err != nil {
// The raw error may embed helm command arguments and output; keep
// the span description generic.
span.SetStatus(codes.Error, "release operation failed")
}
span.End()
}
func sortedLabelPairs(labels map[string]string) []string {
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, k+"="+labels[k])
}
return pairs
}
// releaseErrAsError converts a *ReleaseError to error without the typed-nil
// trap: a nil *ReleaseError must become a nil error, or endReleaseSpan would
// call Error() on a nil pointer.
func releaseErrAsError(relErr *ReleaseError) error {
if relErr == nil {
return nil
}
return relErr
}
// skipUndesired reports whether a release is disabled (not desired); callers
// whose callbacks short-circuit on !release.Desired() pass it so no span or
// metric is emitted for releases that run no operation.
func skipUndesired(release *ReleaseSpec) bool {
return !release.Desired()
}
// doWithReleaseSpan runs do for one release under a helmfile.release.<verb>
// span, recording the returned error on the span, and hands do the span
// context so the release's helm subprocesses nest under the span. It is the
// convenience form used by the iterateOnReleases-based loops.
func (st *HelmState) doWithReleaseSpan(verb string, release ReleaseSpec, workerIndex int, skip func(*ReleaseSpec) bool, do func(gocontext.Context, ReleaseSpec, int) error) error {
if skip != nil && skip(&release) {
// Skipped releases run no operation: no span, no metric. The callback
// still runs so its short-circuit behavior is unchanged.
return do(gocontext.Background(), release, workerIndex)
}
ctx, span := st.startReleaseSpan(verb, &release)
start := time.Now()
err := do(ctx, release, workerIndex)
endReleaseSpan(span, verb, &release, start, err)
return err
}