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. 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. // 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 }