mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 12:11:17 +02:00
Toolchain and CI:
- Bump go directive from 1.26.8 to 1.27.1 (go.mod)
- Use golang:1.27-alpine builder images in all Dockerfiles
- Bump golangci-lint to v2.13.2 (first release with go1.27 support)
- Add CI gate: `go fix -diff` fails when outdated Go patterns are
detected (locally: `make check-modernize`)
Note: darwin binaries now require macOS 13 or later.
Lint fixes required by golangci-lint v2.13.2:
- goconst: ignore tests (all 436 findings were test-only; goconst
got stricter since v2.12 and this option was added for it)
- openai.go: keep deprecated MaxTokens deliberately with a nolint
rationale (max_tokens is the only form universally supported by
OpenAI-compatible backends like One-API, LiteLLM, Ollama shim)
- state.go: drop always-nil flags param from appendChartVersionFlags
(renamed to chartVersionFlags, unparam)
Modernization (go fix ./..., 62 files):
- interface{} -> any, maps.Copy, strings.SplitSeq, range-over-int,
builtin min/max, slices.Contains/ContainsFunc/Sort, WaitGroup.Go,
reflect.Type.Fields(), new(expr)
- exit_error.go: strings.Builder + fmt.Fprintf instead of string
concatenation and WriteString(fmt.Sprintf(...)) (QF1012)
- chart_dependency.go: strings.CutLast for OCI dependency helpers
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Claude <noreply@anthropic.com>
320 lines
12 KiB
Go
320 lines
12 KiB
Go
package state
|
|
|
|
import (
|
|
gocontext "context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"go.opentelemetry.io/otel/trace/noop"
|
|
metricsv1 "go.opentelemetry.io/proto/otlp/metrics/v1"
|
|
v1 "go.opentelemetry.io/proto/otlp/trace/v1"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/helmfile/helmfile/pkg/filesystem"
|
|
"github.com/helmfile/helmfile/pkg/helmexec"
|
|
"github.com/helmfile/helmfile/pkg/telemetry"
|
|
"github.com/helmfile/helmfile/pkg/telemetry/otlptest"
|
|
)
|
|
|
|
// TestReleaseSpanExecNesting drives ReleaseStatuses through the real execer
|
|
// (with a shim binary that answers the version probe and otherwise exits 0)
|
|
// and telemetry enabled, then asserts the release span's subprocess span
|
|
// nests under it — the contract established by the
|
|
// HelmContext.Ctx/execWithContext funnel.
|
|
func TestReleaseSpanExecNesting(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a unix shell shim")
|
|
}
|
|
|
|
rec := otlptest.NewRecorder(t)
|
|
otlptest.SetupTelemetry(t, rec, "helmfile test")
|
|
st, helm := newShimState(t)
|
|
require.Empty(t, st.ReleaseStatuses(helm, 1))
|
|
|
|
otlptest.ShutdownTelemetry(t)
|
|
|
|
spans := rec.Spans(t)
|
|
release := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "helmfile.release.status" }, "release status span")
|
|
|
|
name, ok := otlptest.AttrString(release, "helmfile.release")
|
|
require.True(t, ok)
|
|
assert.Equal(t, "demo", name)
|
|
|
|
// The shim binary's name starts with "helm", so its spans are helm.exec.
|
|
// helmexec.New's version probe also produced one (parented at the command
|
|
// span); the release's own subprocess must be the "status" one, nested
|
|
// under the release span.
|
|
exec := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool {
|
|
sub, ok := otlptest.AttrString(s, "helm.subcommand")
|
|
return s.Name == "helm.exec" && ok && sub == "status"
|
|
}, "release status subprocess span")
|
|
assert.Equal(t, release.TraceId, exec.TraceId, "subprocess must join the release span's trace")
|
|
assert.Equal(t, release.SpanId, exec.ParentSpanId, "subprocess must nest under the release span")
|
|
|
|
assertMetrics(t, rec)
|
|
}
|
|
|
|
// newShimState builds a one-release HelmState driven through the real
|
|
// execer with a shim binary that answers the version probe and otherwise
|
|
// exits 0 without side effects. Telemetry must already be set up.
|
|
func newShimState(t *testing.T) (*HelmState, helmexec.Interface) {
|
|
t.Helper()
|
|
shim := filepath.Join(t.TempDir(), "helm-shim")
|
|
require.NoError(t, os.WriteFile(shim, []byte("#!/bin/sh\ncase \"$1\" in version) echo 'v3.14.0' ;; esac\nexit 0\n"), 0o755))
|
|
|
|
shell := &helmexec.ShellRunner{
|
|
Logger: zap.NewNop().Sugar(),
|
|
Ctx: telemetry.CommandContext(),
|
|
}
|
|
helm, err := helmexec.New(shim, helmexec.HelmExecOptions{}, zap.NewNop().Sugar(), "", "", shell)
|
|
require.NoError(t, err)
|
|
|
|
return &HelmState{
|
|
logger: zap.NewNop().Sugar(),
|
|
fs: filesystem.DefaultFileSystem(),
|
|
Releases: []ReleaseSpec{
|
|
{Name: "demo", Namespace: "apps", Chart: "./charts/demo"},
|
|
},
|
|
}, helm
|
|
}
|
|
|
|
// TestReleaseDurationMetricDefaultDims pins that helmfile.release.duration is
|
|
// exported with only the bounded verb/result dimensions unless
|
|
// HELMFILE_OTEL_METRICS_PER_RELEASE is enabled.
|
|
func TestReleaseDurationMetricDefaultDims(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a unix shell shim")
|
|
}
|
|
rec := otlptest.NewRecorder(t)
|
|
otlptest.SetupTelemetry(t, rec, "helmfile test")
|
|
st, helm := newShimState(t)
|
|
require.Empty(t, st.ReleaseStatuses(helm, 1))
|
|
otlptest.ShutdownTelemetry(t)
|
|
|
|
metrics := rec.Metrics(t)
|
|
duration := otlptest.FindMetric(t, metrics, "helmfile.release.duration")
|
|
dp := findHistogramPoint(t, duration, "verb", "status")
|
|
require.NotNil(t, dp)
|
|
assert.Positive(t, dp.GetCount())
|
|
|
|
seen := map[string]string{}
|
|
for _, attr := range dp.GetAttributes() {
|
|
seen[attr.GetKey()] = attr.GetValue().GetStringValue()
|
|
}
|
|
assert.Equal(t, map[string]string{"verb": "status", "result": "success"}, seen,
|
|
"release identity must NOT be attached by default (bounded cardinality)")
|
|
}
|
|
|
|
// TestReleaseDurationMetricPerRelease pins the opt-in high-cardinality mode:
|
|
// HELMFILE_OTEL_METRICS_PER_RELEASE adds the release name and namespace.
|
|
func TestReleaseDurationMetricPerRelease(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses a unix shell shim")
|
|
}
|
|
rec := otlptest.NewRecorder(t)
|
|
otlptest.SetupTelemetry(t, rec, "helmfile test")
|
|
// Set AFTER SetupTelemetry, whose hermetic env reset clears it first.
|
|
t.Setenv("HELMFILE_OTEL_METRICS_PER_RELEASE", "true")
|
|
st, helm := newShimState(t)
|
|
require.Empty(t, st.ReleaseStatuses(helm, 1))
|
|
otlptest.ShutdownTelemetry(t)
|
|
|
|
duration := otlptest.FindMetric(t, rec.Metrics(t), "helmfile.release.duration")
|
|
dp := findHistogramPoint(t, duration, "helmfile.release", "demo")
|
|
require.NotNil(t, dp, "per-release datapoint must exist when enabled")
|
|
|
|
seen := map[string]string{}
|
|
for _, attr := range dp.GetAttributes() {
|
|
seen[attr.GetKey()] = attr.GetValue().GetStringValue()
|
|
}
|
|
assert.Equal(t, map[string]string{
|
|
"verb": "status",
|
|
"result": "success",
|
|
"helmfile.release": "demo",
|
|
"helmfile.namespace": "apps",
|
|
}, seen)
|
|
}
|
|
|
|
// assertMetrics pins the two helmfile metrics recorded on this path: one
|
|
// helm.exec duration datapoint for the status subcommand, and one successful
|
|
// release.count increment for verb=status.
|
|
func assertMetrics(t *testing.T, rec *otlptest.Recorder) {
|
|
t.Helper()
|
|
|
|
metrics := rec.Metrics(t)
|
|
|
|
duration := otlptest.FindMetric(t, metrics, "helmfile.helm.exec.duration")
|
|
assert.Equal(t, "s", duration.GetUnit())
|
|
dp := findHistogramPoint(t, duration, "subcommand", "status")
|
|
require.NotNil(t, dp, "duration histogram must have a subcommand=status datapoint")
|
|
assert.Positive(t, dp.GetCount(), "histogram must record at least one observation")
|
|
// Buckets must be the seconds-tuned set, not the SDK's millisecond-
|
|
// oriented defaults.
|
|
bounds := dp.GetExplicitBounds()
|
|
require.NotEmpty(t, bounds)
|
|
assert.Less(t, bounds[0], 0.01, "first bucket must resolve sub-second invocations")
|
|
assert.Contains(t, bounds, 1.0)
|
|
assert.Contains(t, bounds, 5.0)
|
|
assert.Greater(t, bounds[len(bounds)-1], 300.0, "top bucket must cover multi-minute waits")
|
|
|
|
releaseDuration := otlptest.FindMetric(t, metrics, "helmfile.release.duration")
|
|
rdp := findHistogramPoint(t, releaseDuration, "verb", "status")
|
|
require.NotNil(t, rdp, "release duration must have a verb=status datapoint")
|
|
assert.Equal(t, "s", releaseDuration.GetUnit())
|
|
|
|
count := otlptest.FindMetric(t, metrics, "helmfile.release.count")
|
|
assert.Equal(t, "{release}", count.GetUnit(), "counters use curly-annotation units")
|
|
sum := sumCounter(t, count, map[string]string{"verb": "status", "result": "success"})
|
|
assert.EqualValues(t, 1, sum, "release.count must count one successful status")
|
|
|
|
// The instrumentation scope carries the helmfile version.
|
|
for _, sm := range rec.ScopeMetrics(t) {
|
|
if sm.Scope.GetName() == "helmfile" {
|
|
assert.Equal(t, "test", sm.Scope.GetVersion(), "scope version must be stamped")
|
|
}
|
|
}
|
|
}
|
|
|
|
func findHistogramPoint(t *testing.T, m *metricsv1.Metric, key, value string) *metricsv1.HistogramDataPoint {
|
|
t.Helper()
|
|
for _, dp := range m.GetHistogram().GetDataPoints() {
|
|
for _, attr := range dp.GetAttributes() {
|
|
if attr.GetKey() == key && attr.GetValue().GetStringValue() == value {
|
|
return dp
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sumCounter(t *testing.T, m *metricsv1.Metric, want map[string]string) int64 {
|
|
t.Helper()
|
|
for _, dp := range m.GetSum().GetDataPoints() {
|
|
matched := true
|
|
seen := map[string]string{}
|
|
for _, attr := range dp.GetAttributes() {
|
|
seen[attr.GetKey()] = attr.GetValue().GetStringValue()
|
|
}
|
|
for k, v := range want {
|
|
if seen[k] != v {
|
|
matched = false
|
|
break
|
|
}
|
|
}
|
|
if matched {
|
|
return dp.GetAsInt()
|
|
}
|
|
}
|
|
t.Fatalf("no %s datapoint with attributes %v", m.GetName(), want)
|
|
return 0
|
|
}
|
|
|
|
// TestSetTraceContext pins the parent wiring: per-release spans root at the
|
|
// trace context handed over by pkg/app (the load span), falling back to
|
|
// Background when unset.
|
|
func TestSetTraceContext(t *testing.T) {
|
|
st := &HelmState{}
|
|
assert.Equal(t, gocontext.Background(), st.releaseSpanParent(), "unset trace context must fall back to Background")
|
|
|
|
type ctxKey struct{}
|
|
ctx := gocontext.WithValue(gocontext.Background(), ctxKey{}, "x")
|
|
st.SetTraceContext(ctx)
|
|
assert.Equal(t, ctx, st.releaseSpanParent())
|
|
}
|
|
|
|
// TestSetCancelContext pins #2770: kubedog tracking roots under the app
|
|
// cancel context so SIGINT reaches buffered helm subprocesses. Unset keeps
|
|
// the historical Background fallback (detached / non-app-cancelable).
|
|
func TestSetCancelContext(t *testing.T) {
|
|
st := &HelmState{}
|
|
assert.Equal(t, gocontext.Background(), st.releaseCancelContext(), "unset cancel context must fall back to Background")
|
|
|
|
ctx, cancel := gocontext.WithCancel(gocontext.Background())
|
|
defer cancel()
|
|
st.SetCancelContext(ctx)
|
|
got := st.releaseCancelContext()
|
|
assert.Equal(t, ctx, got)
|
|
|
|
cancel()
|
|
select {
|
|
case <-got.Done():
|
|
default:
|
|
t.Fatal("releaseCancelContext must become Done when the app cancel context is canceled")
|
|
}
|
|
}
|
|
|
|
// ctxRecordingHelm captures the context handed to it via ContextSwapper so
|
|
// tests can assert which context roots the buffered helm subprocesses. The
|
|
// embedded nil Interface panics on any other method call, which
|
|
// bufferHelmOutput never makes.
|
|
type ctxRecordingHelm struct {
|
|
helmexec.Interface
|
|
ctx gocontext.Context
|
|
}
|
|
|
|
func (h *ctxRecordingHelm) WithLogger(_ *zap.SugaredLogger) helmexec.Interface { return h }
|
|
|
|
func (h *ctxRecordingHelm) WithContext(ctx gocontext.Context) helmexec.Interface {
|
|
h.ctx = ctx
|
|
return h
|
|
}
|
|
|
|
// TestBufferHelmOutputRootsAtCancelContext pins the actual #2770 wiring at
|
|
// the unit level: the release-scoped context that bufferHelmOutput swaps in
|
|
// via WithContext must derive from the app cancel context (so the runner's
|
|
// ctx.Done() SIGINT path fires), and stay non-cancelable when no cancel
|
|
// context is set.
|
|
func TestBufferHelmOutputRootsAtCancelContext(t *testing.T) {
|
|
appCtx, cancel := gocontext.WithCancel(gocontext.Background())
|
|
defer cancel()
|
|
|
|
tracked := &HelmState{logger: zap.NewNop().Sugar()}
|
|
tracked.SetCancelContext(appCtx)
|
|
trackedHelm := &ctxRecordingHelm{}
|
|
tracked.bufferHelmOutput(trackedHelm, tracked.releaseCancelContext(), "demo")
|
|
require.NotNil(t, trackedHelm.ctx, "bufferHelmOutput must swap in a release-scoped context via WithContext")
|
|
|
|
cancel()
|
|
select {
|
|
case <-trackedHelm.ctx.Done():
|
|
default:
|
|
t.Fatal("the context handed to WithContext must become Done when the app cancel context is canceled")
|
|
}
|
|
|
|
detached := &HelmState{logger: zap.NewNop().Sugar()}
|
|
detachedHelm := &ctxRecordingHelm{}
|
|
detached.bufferHelmOutput(detachedHelm, detached.releaseCancelContext(), "demo")
|
|
assert.NoError(t, detachedHelm.ctx.Err(), "unset cancel context must keep the release context detached (Background-rooted)")
|
|
}
|
|
|
|
func TestSkipUndesired(t *testing.T) {
|
|
assert.False(t, skipUndesired(&ReleaseSpec{}), "installed unset means desired")
|
|
|
|
installed := false
|
|
assert.True(t, skipUndesired(&ReleaseSpec{Installed: &installed}), "installed=false must be skipped")
|
|
|
|
installed = true
|
|
assert.False(t, skipUndesired(&ReleaseSpec{Installed: &installed}), "installed=true must run")
|
|
}
|
|
|
|
// TestTraceOnlyContextParentsToRelease pins the trace-only bridge: with a
|
|
// parent (the release span context) hooks attach to it, stay non-cancellable,
|
|
// and fall back to the command context without one.
|
|
func TestTraceOnlyContextParentsToRelease(t *testing.T) {
|
|
noopTracer := noop.NewTracerProvider().Tracer("test")
|
|
parentCtx, parentSpan := noopTracer.Start(gocontext.Background(), "helmfile.release.sync")
|
|
|
|
hookCtx := traceOnlyContext(parentCtx)
|
|
|
|
assert.Equal(t, parentSpan, trace.SpanFromContext(hookCtx), "bridged context must carry the release span")
|
|
assert.Nil(t, hookCtx.Done(), "bridged context must remain non-cancellable (historical behavior)")
|
|
|
|
fallback := traceOnlyContext()
|
|
assert.Equal(t, trace.SpanFromContext(telemetry.CommandContext()), trace.SpanFromContext(fallback), "no parent falls back to the command span")
|
|
}
|