mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 08:50:54 +02:00
* feat(telemetry): add opt-in OpenTelemetry tracing (PR 1: lifecycle + root span) Implements the first increment of docs/proposals/otel-tracing.md (#2767): - pkg/telemetry: SDK setup from standard OTEL_* env vars (autoexport for exporter selection, env-driven sampler/propagators, OTEL_SDK_DISABLED), command-span lifecycle, no-op-by-default accessors - --otel-tracing flag / HELMFILE_OTEL_TRACING env switch - root span "helmfile <command>" with file/environment/selectors/exit_code attributes; TRACEPARENT-based remote-parent extraction for CI correlation - shutdown flush on both normal-exit and signal paths (nil-safe, 5s bound) - app.New derives its context from telemetry.CommandContext() (Background-identical when tracing is disabled) - docs: otel.md user guide, experimental-features entry, design proposal - tests: hermetic unit tests, app context-contract pinning, flag registration Telemetry problems never fail a run: exporter misconfiguration and export errors degrade to disabled with a warning. When disabled, behavior and performance are identical to before (no-op tracer, no goroutines, no network). Refs: #2767, #2758 Signed-off-by: yxxhero <aiopsclub@163.com> * feat(telemetry): trace every external process + trace-context bridges (PR 2) Implements the second increment of docs/proposals/otel-tracing.md (#2767): - pkg/helmexec/span.go: one span per external process started by helmfile (helm invocations, hooks, plugin execs) at the ShellRunner choke point — helm.exec (with helm.subcommand) vs os.exec, with redacted exec.args, exec.exit_code, and error status on failure - pkg/helmexec/redact.go: shared argument redaction with two profiles; legacy is byte-identical to the historical exit-error behavior (existing goldens unchanged), strict (spans) additionally covers --set=k=v and credential flags; exit_error.go now uses the shared helper - orphan-trace bridges with bit-identical cancellation semantics (context.WithoutCancel of the command context): both kubedog call sites (state.go) and hook execution (event.Bus gains an optional Ctx consumed by its default runner; state.go sets it, nil falls back to TODO as before) - OTLP end-to-end test (in-process httptest receiver, no external collector): span export, error status/exit code, redaction, and parent-linkage to the command span - docs/otel.md updated to the now-traced surface Verified end-to-end with the console exporter: helmfile template on a local chart yields the command span plus helm.exec spans for helm version/dependency/template, all nested under it. Refs: #2767 Signed-off-by: yxxhero <aiopsclub@163.com> * feat(telemetry): state-loading and hook spans (PR 3a) Implements the third increment of docs/proposals/otel-tracing.md (#2767): - helmfile.discover_states around findDesiredStateFiles and helmfile.load around loadDesiredStateFromYamlWithBaseDir; both cover all callers (incl. nested helmfiles) with no signature changes - helmfile.render / helmfile.parse children per document part, parented through a traceCtx field on the unexported desiredStateLoader struct (set once at its single construction site) - helmfile.hook span per hook execution: Trigger's per-hook body extracted into runHook (readability win on its own), the hook's subprocess span nests under it via a per-hook ctx-swapped ShellRunner clone (cancellation unchanged — Bus.Ctx never carries cancellation by contract) - pkg/telemetry/otlptest: shared in-process OTLP/HTTP receiver harness, now used by helmexec, event, and app span tests - golden span-tree test at the app layer (root -> discover -> load -> render/parse, via the exectest fake helm) and a hook-span nesting test - nil-ctx guard for App literals built directly by tests (App.spanParentCtx) Verified end-to-end with the console exporter: a template run over a gotmpl state file with a prepare hook yields the full tree with the hook's os.exec nested under helmfile.hook. Refs: #2767 Signed-off-by: yxxhero <aiopsclub@163.com> * feat(telemetry): per-release spans nested under the load span (PR 3b) Implements the per-release increment of docs/proposals/otel-tracing.md (#2767) — spans nest command -> load -> release -> helm exec: - helmexec.HelmContext gains an optional Ctx carrying the per-release span context; the execer's new execWithContext funnel consumes it via a per-call runner clone (runnerWithCtx) so the shared, cached execer is never mutated across concurrent workers. The seven Interface methods that take a HelmContext (Sync/Diff/ReleaseStatus/List/DecryptSecret/ Delete/Test) route through it; nil Ctx behaves exactly as before. exec() lost its always-nil override parameter on the way (unparam). - pkg/state/span.go: SetTraceContext + startReleaseSpan/endReleaseSpan helpers (release/namespace/chart/labels attributes, sorted for stable output); a typed-nil guard (releaseErrAsError) avoids the classic nil-pointer-in-interface trap on *ReleaseError. - release spans in the worker loops: SyncReleases, DiffReleases, DeleteReleasesForSync, PrepareCharts, and iterateOnReleases (status/ delete/test via a new verb parameter); their HelmContext is stamped with the release span context where one is built. - pkg/app sets st.SetTraceContext(loadCtx) right after loading a state file, rooting all per-release spans under helmfile.load. - bridged one more detached tracking call found on the way (trackReleaseIfEnabled's context.Background in the sync worker). - golden test: release span present, nested under load, correct attributes; unit test for the runnerWithCtx clone semantics. Verified with the console exporter: helmfile template yields release.prepare(demo) under load with full attributes. Refs: #2767 Signed-off-by: yxxhero <aiopsclub@163.com> * feat(telemetry): nest status/delete/test execs under their release spans Completes the per-release exec nesting for the iterateOnReleases-based loops (docs/proposals/otel-tracing.md §4.4 phase 2): the do closures now receive the release span context and stamp it into their HelmContext, so helm status/delete/test subprocess spans nest under helmfile.release.<verb> like sync/diff already did. - scatterGatherReleases/iterateOnReleases/doWithReleaseSpan: do gains a context parameter (the release span context) - ReleaseStatuses/DeleteReleases/TestReleases closures stamp HelmContext.Ctx from it - integration test with a real execer (version-probe shim binary): the release's status subprocess nests under helmfile.release.status, same trace, with helm.subcommand=status This also makes the otel.md claim ("upgrade, diff, delete, status, test nested under the release span") fully accurate. Refs: #2767 Signed-off-by: yxxhero <aiopsclub@163.com> * feat(telemetry): OTel metrics — helm exec duration and release results (PR 4) Implements the metrics increment of docs/proposals/otel-tracing.md (#2767) on the same provider, switch, and resource as traces: - pkg/telemetry/metrics.go: helmfile.helm.exec.duration histogram (subcommand, success) and helmfile.release.count counter (verb, result). Instruments come from the otel global meter, so recording at call sites is branch-free no-op when telemetry is disabled. - Setup builds the resource once and installs both providers; reader selection delegates to autoexport (OTEL_METRICS_EXPORTER: otlp | console | prometheus | none), the OTLP reader's interval honors OTEL_METRIC_EXPORT_INTERVAL (read by the SDK). Shutdown flushes both providers (errors.Join). StartCommandSpan now carries the meter provider across state transitions (fixes a nil-shutdown panic). - helmexec: finishExecSpan records exec duration for helm binaries; state: endReleaseSpan counts release outcomes for sync/diff/delete/ status/test/prepare (diff counted as success when no hard error). - otlptest: recorder routes by OTLP path (/v1/traces vs /v1/metrics) and decodes metrics; new FindMetric helper. - tests: metrics recorded as no-op when disabled, provider enabled with the none exporter, degradation on an invalid metrics exporter, and an integration assertion (status exec duration datapoint + one successful release.count) in the shim-based state test. Verified with the console exporter: helmfile template emits helmfile.helm.exec.duration per subcommand (version/dependency/ template) and helmfile.release.count{verb=prepare,result=success}=1. Refs: #2767 Signed-off-by: yxxhero <aiopsclub@163.com> * docs: complete OTel documentation coverage - docs/cli.md: --otel-tracing in the CLI reference help block (verbatim from the cobra output) - CHANGELOG.md: [Unreleased] Added entry for tracing + metrics - docs/index.md: Observability highlight linking docs/otel.md - docs/proposals/otel-tracing.md: add OTEL_METRICS_EXPORTER / OTEL_METRIC_EXPORT_INTERVAL rows to the env-var table and note the periodic reader + bounded metric cardinality in §7 Refs: #2767 Signed-off-by: yxxhero <aiopsclub@163.com> * fix: drop unused id parameter from parsePart (unparam) The id parameter was never used inside the span wrapper; the caller's id variable is still used for the render calls and error messages. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix(telemetry): address review — redaction gaps, kubedog valve, phantom metrics Addresses all Copilot review comments on #2769: Security (span payloads): - exec.args: positional arguments are additionally passed through helmexec.RedactedURL, so credentials embedded in chart/repository URLs (AddRepo, RegistryLogin, OCI refs) are masked exactly like log output - release spans sanitize helmfile.chart the same way - error statuses no longer embed raw errors (which contain rendered commands, arguments, and subprocess output): the command span, release spans, hook spans, and exec spans now use generic descriptions; the concrete exit code remains an attribute, and RecordError on the root span is dropped Correctness: - kubedog safety valve restored: execWithContext now attaches the per-release span into the runner's own context instead of replacing it, so trackHandle.Cancel() can interrupt a wedged helm again and app cancellation semantics stay exactly as before the PR - diff release spans/metrics: real failures are recorded (exit code 2 "changes detected" still counts as success); previously every diff was exported as successful - skipped releases no longer emit phantom spans and inflate helmfile.release.count: iterateOnReleases callers pass a skip predicate (skipUndesired for status/test; delete deletes undesired releases and passes nil) - Setup shuts down the already-constructed tracer provider (bounded) when the metrics provider fails, instead of abandoning its batch goroutine Tests: URL redaction cases (masked/untouched), spanAttachedContext preserves the runner cancellation chain while attaching the caller's span, skipUndesired, and a failing-hook span asserting the generic message. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix: lint — restore nolint placement and avoid nil context literal - the skipUndesired insertion had displaced the // nolint: unparam directive off iterateOnReleases (helm param is intentionally unused there); also fixes a skipDesired/skipUndesired comment typo - use a typed nil in TestSpanAttachedContext (staticcheck SA1012) Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix(telemetry): address review round 2 — remote-ref redaction, wrapper helm binaries, hook release attribution Addresses all 6 new review comments on #2769: Security (remote references): - new helmexec.RedactedRef sanitizes go-getter style references for telemetry: forced-form prefixes (git::, s3::) preserved, whole URL userinfo masked (usernames carry tokens too), credential-bearing query parameters masked using pkg/remote's heuristic (token/password/secret/ key/signature). Applied to helmfile.file (command span), helmfile.path (discover_states), helmfile.chart (release spans), and exec.args — log-time RedactedURL is untouched so log output is unchanged Correctness: - wrapper helm binaries (--helm-binary custom names) are now classified as helm operations by an explicit context marker stamped in the execer funnel, instead of the executable-basename heuristic; the same classification gates helmfile.helm.exec.duration, so the metric no longer misses wrapper invocations (classifyExec) - release-scoped hooks (presync/postsync/preuninstall/postuninstall/ cleanup in the sync/delete/diff workers) now attach their helmfile.hook spans to the active helmfile.release.* span via a variadic parent on the trigger functions; global hooks keep the command context and all 29 existing call sites compile unchanged; hook cancellation stays detached (WithoutCancel) as before - signal-terminated runs (Shutdown with exitCode 130/143 and nil error) now mark the command span with error status, consistent with their nonzero exit code Tests: RedactedRef table (forced forms, userinfo, s3/token query params, untouched cases), classifyExec marker case, hookTraceContext parent attribution + non-cancellability + fallback. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix(telemetry): address review round 3 — redaction corner cases, value runners Addresses 5 of the 6 new review comments on #2769 (the sixth — an unused strings import in exit_error.go — is a false positive: Indent still uses strings.Split/Builder and the package compiles): - RedactArgs read the previous token from the progressively redacted output, so {--set, --set-string, secret} leaked the secret (the masked value hid the following flag). Read the previous token from the original input, restoring the legacy contract for adjacent secret flags - RedactedRef fails closed for malformed references: URL-like refs with invalid percent escapes export a fully redacted value, and an unparseable query is dropped entirely instead of exported verbatim - ShellRunner has value receivers, so a ShellRunner VALUE satisfies the Runner API; the helm marker stamping and the per-release span attachment now handle both value and pointer forms (matching WithContext), so value-runner callers keep release nesting and the helm.exec classification/metric Regression tests: adjacent secret flags (legacy + strict), malformed URL-like ref, malformed query, value-runner marker + span attachment. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix(telemetry): stamp the helm marker on the stdin funnel too execStdIn (registry login, repo add) called the runner directly, so wrapper --helm-binary names were misclassified as os.exec and omitted from helmfile.helm.exec.duration on that path. The marking now goes through a shared markHelmRunner helper (value and pointer ShellRunner forms) used by both execution funnels. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix(telemetry): redact helm's --kube-token in strict profile Helm's global --kube-token carries a bearer token; both the two-argument and inline forms are now masked in span exec.args (legacy exit-error output is untouched, matching its historical behavior). Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * fix(telemetry): OTel metrics best-practice alignment - helmfile.helm.exec.duration now declares explicit bucket boundaries tuned for seconds-scale helm invocations (5ms…600s); the SDK defaults are millisecond-oriented and lumped every sub-5s invocation — the common case — into the first bucket, defeating the histogram - instruments are re-created under the installed provider with the instrumentation scope version stamped (Setup-time, race-free) - helmfile.release.count declares the {release} curly-annotation unit per the metrics naming conventions Tested end-to-end via the OTLP integration test: exported bounds are the tuned set, units are asserted, and the scope carries the version. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * feat(telemetry): per-release duration metrics behind an opt-in switch New helmfile.release.duration histogram (seconds, same tuned buckets) with bounded dimensions by default (verb, result). Setting HELMFILE_OTEL_METRICS_PER_RELEASE=true adds helmfile.release and helmfile.namespace, answering "which release is slow" from dashboards: - well-suited to bounded CI runs; long-lived centralized collection needs a backend capacity/TTL story (documented in docs/otel.md) - per-release timing remains available in traces without the flag - env read per call (release operations are low-frequency, and tests toggle it) endReleaseSpan now takes the release and the operation start time; the five worker-loop call sites pass them (doWithReleaseSpan, SyncReleases, DeleteReleasesForSync, PrepareCharts, DiffReleases). Verified end-to-end with the console exporter (default dims vs per-release) and OTLP integration tests pinning both modes. Refs: #2767, #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * refactor(telemetry): maintainability pass over the runner/metric plumbing - StartCommandSpan copies the tracingState struct instead of enumerating fields by hand — that pattern dropped the meter provider once already - the two value/pointer ShellRunner switches (helm marker, span attachment) are unified into one withRunnerCtx helper; the duplication caused two review rounds of value-form misses - classifyExec derives the helm classification from the span name (helmExecSpanName constant) instead of returning a third parallel bool - metrics: shared outcomeAttrs for the verb/result dimensions, and the bucket slice renamed to durationBuckets with a comment covering both histograms that use it No behavior change; full -race suite green, lint clean. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * refactor(telemetry): consolidate test env lists, trace bridges, and hook prep; sync the design doc Maintainability: - HermeticEnvVars is now exported from pkg/telemetry (the owner of the env surface) and used by both telemetry tests and otlptest — the two copies had already drifted once (HELMFILE_OTEL_METRICS_PER_RELEASE needed updating in both) - kubedogTraceContext and hookTraceContext were the same concept written twice; unified into traceOnlyContext(parent...) in span.go Readability: - runHook's nested kubectl rewrite extracted into prepareKubectlHook with guard-clause structure Accuracy (docs ↔ code, drifted over five review rounds): - §4.4 now describes the implemented mechanism: the release span is INJECTED into the runner's own context (preserving the kubedog safety valve) rather than the runner context being replaced, and helm classification is marker-based for wrapper binaries - §5 exec span rows list the actual attributes incl. URL/query masking - §6 strict profile documents RedactedRef, --kube-token, and the adjacent-token guarantee No behavior change; full -race suite green, lint clean. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * refactor(telemetry): drop the dead noop state, relocate skipUndesired, sync user-facing accuracy - tracingState.noop was dead weight in the enabled state and a copy-surface in every transition; a single package-level noopTracerProvider now backs Tracer while disabled - skipUndesired moved next to doWithReleaseSpan in span.go, its only conceptual home (span/metric suppression, not run plumbing) - accuracy: the package doc, --otel-tracing flag help, experimental-features entry, and CHANGELOG now all say tracing AND metrics and list the third instrument (helmfile.release.duration with the HELMFILE_OTEL_METRICS_PER_RELEASE opt-in) — these had drifted when the metric was added; the PR description's metric table is updated to match as well No behavior change; full -race suite green (except the pre-existing network-dependent TestStorage_resolveFile flake), lint clean. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * refactor(telemetry): flatten Setup, name the prefix bound, dedupe test fake; fix instrument-count drift Readability/maintainability: - Setup drops from 56 to 39 lines: provider construction (including the shutdown-tracer-on-meter-failure recovery) moves to newProviders in exporter.go next to the constructors it composes - refredact's magic 16 becomes maxForcedFormPrefix with a comment - span_test's hand-rolled fakeRunner removed in favor of the existing mockRunner (same package) Accuracy: - "Two instruments" wording survived in docs/otel.md and the design proposal §7 after helmfile.release.duration was added; both now say three and mention the per-release opt-in No behavior change; full -race suite green (except the pre-existing network flake), lint clean. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> * refactor(telemetry): co-locate span machinery, drop a dead export, fix docs nits - the span plumbing helpers (markHelmExec, withRunnerCtx, markHelmRunner, spanAttachedContext) move from exec.go to span.go, next to the marker type and classifiers they serve — exec.go keeps only the funnel call sites - otlptest.SpanNames was never used outside the package; unexported - isHelmBinary's comment now states it is the FALLBACK classifier (funnel invocations are marker-classified), replacing the outdated "cosmetic distinction" framing from before the marker existed - docs/otel.md: release-scoped hooks nest under their release span (added in review round 2, never documented) No behavior change; full -race suite green, lint clean. Refs: #2769 Signed-off-by: yxxhero <aiopsclub@163.com> --------- Signed-off-by: yxxhero <aiopsclub@163.com>
2206 lines
72 KiB
Go
2206 lines
72 KiB
Go
package helmexec
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Masterminds/semver/v3"
|
|
"github.com/google/go-cmp/cmp"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tj/assert"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// pluginCmd is the "plugin" helm subcommand used repeatedly across these tests.
|
|
// Extracted as constants so the repeated string literals do not trip goconst
|
|
// (min-occurrences: 8) once additional plugin tests are added.
|
|
const (
|
|
pluginCmd = "plugin"
|
|
installCmd = "install"
|
|
)
|
|
|
|
// Mocking the command-line runner
|
|
|
|
type mockRunner struct {
|
|
output []byte
|
|
versionOutput []byte // if set, returned for "helm version --short" probe; overrides default Helm 4 fallback
|
|
err error
|
|
// failCount is the number of times Execute/ExecuteStdIn fail (with
|
|
// errTransientNet) before succeeding. Used to exercise retry logic.
|
|
failCount int
|
|
calls int
|
|
// stdins records the stdin content passed to each ExecuteStdIn call,
|
|
// so tests can assert that retries still pass the full payload (e.g. the
|
|
// password is not consumed by a prior attempt).
|
|
stdins []string
|
|
// execArgs records the args passed to each Execute/ExecuteStdIn call, so
|
|
// tests can assert that retries don't accumulate duplicated flags.
|
|
execArgs [][]string
|
|
}
|
|
|
|
var errTransientNet = fmt.Errorf("transient network error")
|
|
|
|
func (mock *mockRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
|
|
mock.calls++
|
|
mock.execArgs = append(mock.execArgs, append([]string{}, args...))
|
|
if stdin != nil {
|
|
b, _ := io.ReadAll(stdin)
|
|
mock.stdins = append(mock.stdins, string(b))
|
|
}
|
|
if mock.failCount > 0 && mock.calls <= mock.failCount {
|
|
return nil, errTransientNet
|
|
}
|
|
return mock.output, mock.err
|
|
}
|
|
|
|
func (mock *mockRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
if strings.Join(args, " ") == "version --short" {
|
|
if mock.versionOutput != nil {
|
|
return mock.versionOutput, nil
|
|
}
|
|
if len(mock.output) == 0 {
|
|
return []byte("v4.0.1+g12500dd"), nil
|
|
}
|
|
}
|
|
mock.calls++
|
|
mock.execArgs = append(mock.execArgs, append([]string{}, args...))
|
|
if mock.failCount > 0 && mock.calls <= mock.failCount {
|
|
return nil, errTransientNet
|
|
}
|
|
return mock.output, mock.err
|
|
}
|
|
|
|
func MockExecer(logger *zap.SugaredLogger, kubeconfig, kubeContext string) (*execer, error) {
|
|
execer, err := New("helm", HelmExecOptions{}, logger, kubeconfig, kubeContext, &mockRunner{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return execer, nil
|
|
}
|
|
|
|
// Test methods
|
|
|
|
func TestNewHelmExec(t *testing.T) {
|
|
buffer := bytes.NewBufferString("something")
|
|
helm, err := MockExecer(NewLogger(buffer, "debug"), "config", "dev")
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
if helm.kubeContext != "dev" {
|
|
t.Error("helmexec.New() - kubeContext")
|
|
}
|
|
if buffer.String() != "something" {
|
|
t.Error("helmexec.New() - changed buffer")
|
|
}
|
|
if len(helm.extra) != 0 {
|
|
t.Error("helmexec.New() - extra args not empty")
|
|
}
|
|
}
|
|
|
|
func Test_SetExtraArgs(t *testing.T) {
|
|
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
helm.SetExtraArgs()
|
|
if len(helm.extra) != 0 {
|
|
t.Error("helmexec.SetExtraArgs() - passing no arguments should not change extra field")
|
|
}
|
|
helm.SetExtraArgs("foo")
|
|
if !reflect.DeepEqual(helm.extra, []string{"foo"}) {
|
|
t.Error("helmexec.SetExtraArgs() - one extra argument missing")
|
|
}
|
|
helm.SetExtraArgs("alpha", "beta")
|
|
if !reflect.DeepEqual(helm.extra, []string{"alpha", "beta"}) {
|
|
t.Error("helmexec.SetExtraArgs() - two extra arguments missing (overwriting the previous value)")
|
|
}
|
|
}
|
|
|
|
func Test_SetHelmBinary(t *testing.T) {
|
|
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
if helm.helmBinary != "helm" {
|
|
t.Error("helmexec.command - default command is not helm")
|
|
}
|
|
helm.SetHelmBinary("foo")
|
|
if helm.helmBinary != "foo" {
|
|
t.Errorf("helmexec.SetHelmBinary() - actual = %s expect = foo", helm.helmBinary)
|
|
}
|
|
}
|
|
|
|
func Test_SetEnableLiveOutput(t *testing.T) {
|
|
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
if helm.options.EnableLiveOutput {
|
|
t.Error("helmexec.options.EnableLiveOutput should not be enabled by default")
|
|
}
|
|
helm.SetEnableLiveOutput(true)
|
|
if !helm.options.EnableLiveOutput {
|
|
t.Errorf("helmexec.SetEnableLiveOutput() - actual = %t expect = true", helm.options.EnableLiveOutput)
|
|
}
|
|
}
|
|
|
|
func Test_AddRepo_Helm_Version(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
disableForceUpdate bool
|
|
expected string
|
|
}{
|
|
{
|
|
name: "Helm 3.2.0 (before force-update)",
|
|
version: "3.2.0",
|
|
expected: `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --cert-file cert.pem --key-file key.pem
|
|
`,
|
|
},
|
|
{
|
|
name: "Helm 3.3.2 (force-update added)",
|
|
version: "3.3.2",
|
|
expected: `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
|
|
`,
|
|
},
|
|
{
|
|
name: "Helm 3.19.2 (with force-update)",
|
|
version: "3.19.2",
|
|
expected: `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
|
|
`,
|
|
},
|
|
{
|
|
name: "Helm 3.19.2 (force-update disabled)",
|
|
version: "3.19.2",
|
|
disableForceUpdate: true,
|
|
expected: `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --cert-file cert.pem --key-file key.pem
|
|
`,
|
|
},
|
|
{
|
|
name: "Helm 4.0.1 (force-update needed)",
|
|
version: "4.0.1",
|
|
expected: `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
|
|
`,
|
|
},
|
|
{
|
|
name: "Helm 4.0.1 (force-update disabled)",
|
|
version: "4.0.1",
|
|
disableForceUpdate: true,
|
|
expected: `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --cert-file cert.pem --key-file key.pem
|
|
`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse(tt.version),
|
|
logger: logger,
|
|
kubeconfig: "config",
|
|
kubeContext: "dev",
|
|
runner: &mockRunner{},
|
|
options: HelmExecOptions{DisableForceUpdate: tt.disableForceUpdate},
|
|
}
|
|
err := helm.AddRepo("myRepo", "https://repo.example.com/", "", "cert.pem", "key.pem", "", "", "", false, false)
|
|
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tt.expected, buffer.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_AddRepo(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Test case with certfile and keyfile
|
|
buffer.Reset()
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "cert.pem", "key.pem", "", "", "", false, false)
|
|
expected := `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --cert-file cert.pem --key-file key.pem
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test case with cafile
|
|
buffer.Reset()
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "ca.crt", "", "", "", "", "", false, false)
|
|
expected = `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --ca-file ca.crt
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test case with no certfile or cafile
|
|
buffer.Reset()
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "", "", "", false, false)
|
|
expected = `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test case with managed "acr"
|
|
buffer.Reset()
|
|
err = helm.AddRepo("acrRepo", "", "", "", "", "", "", "acr", false, false)
|
|
expected = `Adding repo acrRepo (acr)
|
|
exec: az acr helm repo add --name acrRepo
|
|
exec: az acr helm repo add --name acrRepo:
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test case with unknown managed type
|
|
buffer.Reset()
|
|
err = helm.AddRepo("otherRepo", "", "", "", "", "", "", "unknown", false, false)
|
|
expected = `ERROR: unknown type 'unknown' for repository otherRepo
|
|
`
|
|
if err == nil {
|
|
t.Errorf("expected error for unknown managed type, got nil")
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test case with username and password (using password-stdin)
|
|
buffer.Reset()
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "example_user", "example_password", "", false, false)
|
|
expected = `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --username example_user --password-stdin
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test case with username, password, and pass-credentials
|
|
buffer.Reset()
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "example_user", "example_password", "", true, false)
|
|
expected = `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --pass-credentials --username example_user --password-stdin
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
actual := buffer.String()
|
|
if actual != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
|
|
// Test case with skipTLSVerify
|
|
buffer.Reset()
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "", "", "", false, true)
|
|
expected = `Adding repo myRepo https://repo.example.com/
|
|
exec: helm --kubeconfig config --kube-context dev repo add myRepo https://repo.example.com/ --force-update --insecure-skip-tls-verify
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
normalize := func(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
actual = normalize(buffer.String())
|
|
expected = normalize(expected)
|
|
|
|
if actual != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
|
|
// Test case with empty name
|
|
buffer.Reset()
|
|
err = helm.AddRepo("", "https://repo.example.com/", "", "", "", "", "", "", false, false)
|
|
expected = `empty field name`
|
|
|
|
if err != nil && err.Error() != "empty field name" {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
actual = strings.TrimSpace(buffer.String())
|
|
if actual != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
}
|
|
|
|
func Test_UpdateRepo(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.UpdateRepo()
|
|
expected := `Updating repo
|
|
exec: helm --kubeconfig config --kube-context dev repo update
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.UpdateRepo()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_RegistryLogin(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse("v3.12.0"),
|
|
logger: logger,
|
|
kubeconfig: "config",
|
|
kubeContext: "dev",
|
|
runner: &mockRunner{},
|
|
}
|
|
err := helm.RegistryLogin("repo.example.com", "example_user", "example_password", "example_ca", "example_cert", "example_key", true)
|
|
expected := `Logging in to registry
|
|
exec: helm --kubeconfig config --kube-context dev registry login repo.example.com --cert-file example_cert --key-file example_key --ca-file example_ca --insecure --username example_user --password-stdin
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.RegistryLogin()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test helm version prior to v3.12.0, without support for TLS
|
|
buffer.Reset()
|
|
helm.version = semver.MustParse("v3.11.0")
|
|
|
|
err = helm.RegistryLogin("repo.example.com", "example_user", "example_password", "example_ca", "example_cert", "example_key", true)
|
|
expected = `Logging in to registry
|
|
exec: helm --kubeconfig config --kube-context dev registry login repo.example.com --insecure --username example_user --password-stdin
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.RegistryLogin()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
// newRetryExecer builds an execer with the given RepoRetry option and a
|
|
// mockRunner that fails failCount times before succeeding.
|
|
func newRetryExecer(t *testing.T, repoRetry, failCount int) (*execer, *mockRunner) {
|
|
t.Helper()
|
|
logger := NewLogger(os.Stdout, "debug")
|
|
runner := &mockRunner{failCount: failCount}
|
|
helm, err := New("helm", HelmExecOptions{RepoRetry: repoRetry}, logger, "config", "dev", runner)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
return helm, runner
|
|
}
|
|
|
|
// withTestBackoff shrinks repoRetryBaseBackoff for the test's duration so retry
|
|
// tests don't sleep for real. The restore is via t.Cleanup so it always runs.
|
|
func withTestBackoff(t *testing.T) {
|
|
t.Helper()
|
|
original := repoRetryBaseBackoff
|
|
repoRetryBaseBackoff = time.Millisecond
|
|
t.Cleanup(func() { repoRetryBaseBackoff = original })
|
|
}
|
|
|
|
// retryTestCase is the shared shape for the AddRepo/UpdateRepo/RegistryLogin
|
|
// retry table tests.
|
|
type retryTestCase struct {
|
|
name string
|
|
repoRetry int
|
|
failCount int
|
|
wantErr bool
|
|
wantCalls int // expected number of exec (non-version) calls
|
|
managed string
|
|
}
|
|
|
|
func runRetryCases(t *testing.T, run func(*testing.T, *execer, *mockRunner, retryTestCase), cases []retryTestCase) {
|
|
t.Helper()
|
|
withTestBackoff(t)
|
|
for _, tt := range cases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
helm, runner := newRetryExecer(t, tt.repoRetry, tt.failCount)
|
|
run(t, helm, runner, tt)
|
|
if runner.calls != tt.wantCalls {
|
|
t.Errorf("runner.calls = %d, want %d", runner.calls, tt.wantCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_AddRepo_Retry(t *testing.T) {
|
|
runRetryCases(t, func(t *testing.T, helm *execer, runner *mockRunner, tt retryTestCase) {
|
|
err := helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "user", "pass", tt.managed, false, false)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("AddRepo() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
// For the normal (non-acr) path, each attempt's args must contain
|
|
// --username exactly once: guards against the username/password flags
|
|
// accumulating across retries.
|
|
if tt.managed == "" {
|
|
for i, a := range runner.execArgs {
|
|
if c := strings.Count(strings.Join(a, " "), "--username"); c != 1 {
|
|
t.Errorf("AddRepo() attempt %d: --username count = %d, want 1 (args=%v)", i+1, c, a)
|
|
}
|
|
}
|
|
}
|
|
}, []retryTestCase{
|
|
{name: "succeeds after one retry", repoRetry: 2, failCount: 1, wantErr: false, wantCalls: 2},
|
|
{name: "retries exhausted", repoRetry: 1, failCount: 2, wantErr: true, wantCalls: 2},
|
|
{name: "retry disabled runs once", repoRetry: 0, failCount: 1, wantErr: true, wantCalls: 1},
|
|
{name: "acr managed retries", repoRetry: 3, failCount: 2, wantErr: false, wantCalls: 3, managed: "acr"},
|
|
})
|
|
}
|
|
|
|
func Test_UpdateRepo_Retry(t *testing.T) {
|
|
runRetryCases(t, func(t *testing.T, helm *execer, _ *mockRunner, tt retryTestCase) {
|
|
err := helm.UpdateRepo()
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("UpdateRepo() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
}, []retryTestCase{
|
|
{name: "succeeds after one retry", repoRetry: 2, failCount: 1, wantErr: false, wantCalls: 2},
|
|
{name: "retries exhausted", repoRetry: 1, failCount: 2, wantErr: true, wantCalls: 2},
|
|
{name: "retry disabled runs once", repoRetry: 0, failCount: 1, wantErr: true, wantCalls: 1},
|
|
})
|
|
}
|
|
|
|
func Test_RegistryLogin_Retry(t *testing.T) {
|
|
runRetryCases(t, func(t *testing.T, helm *execer, runner *mockRunner, tt retryTestCase) {
|
|
err := helm.RegistryLogin("repo.example.com", "user", "pass", "", "", "", false)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("RegistryLogin() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
// Every attempt must carry the full password (regression guard:
|
|
// the password buffer must not be consumed by a prior attempt).
|
|
for i, s := range runner.stdins {
|
|
if s != "pass\n" {
|
|
t.Errorf("RegistryLogin() attempt %d stdin = %q, want %q", i+1, s, "pass\n")
|
|
}
|
|
}
|
|
}, []retryTestCase{
|
|
{name: "succeeds after one retry", repoRetry: 2, failCount: 1, wantErr: false, wantCalls: 2},
|
|
{name: "retries exhausted", repoRetry: 1, failCount: 2, wantErr: true, wantCalls: 2},
|
|
{name: "retry disabled runs once", repoRetry: 0, failCount: 1, wantErr: true, wantCalls: 1},
|
|
})
|
|
}
|
|
|
|
// Test_Retry_LargeCount_NoOverflow verifies that a large --repo-retries value
|
|
// doesn't overflow the backoff duration (the shift exponent must be capped).
|
|
func Test_Retry_LargeCount_NoOverflow(t *testing.T) {
|
|
withTestBackoff(t)
|
|
// failCount > repoRetry so every attempt fails; attempt indices exceed the
|
|
// shift cap of 5, exercising the overflow guard without panicking.
|
|
const retries = 10
|
|
helm, runner := newRetryExecer(t, retries, retries+1)
|
|
err := helm.UpdateRepo()
|
|
if err == nil {
|
|
t.Fatal("UpdateRepo() expected error, got nil")
|
|
}
|
|
if runner.calls != retries+1 { // 1 initial + retries
|
|
t.Errorf("runner.calls = %d, want %d", runner.calls, retries+1)
|
|
}
|
|
}
|
|
|
|
// Test_SleepCtx_Canceled verifies sleepCtx returns promptly (and false) when
|
|
// the runner's context is already canceled, rather than blocking for the full
|
|
// duration.
|
|
func Test_SleepCtx_Canceled(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // cancel immediately
|
|
helm := &execer{
|
|
runner: &ShellRunner{Ctx: ctx},
|
|
logger: NewLogger(io.Discard, "debug"),
|
|
}
|
|
start := time.Now()
|
|
completed := helm.sleepCtx(30 * time.Second)
|
|
elapsed := time.Since(start)
|
|
if completed {
|
|
t.Error("sleepCtx() = true with a canceled context; want false")
|
|
}
|
|
if elapsed > time.Second {
|
|
t.Errorf("sleepCtx blocked for %v with a canceled context; expected prompt return", elapsed)
|
|
}
|
|
}
|
|
|
|
// Test_SleepCtx_NoContext verifies sleepCtx falls back to time.Sleep (returning
|
|
// true) when the runner has no context (e.g. mockRunner).
|
|
func Test_SleepCtx_NoContext(t *testing.T) {
|
|
helm := &execer{
|
|
runner: &mockRunner{},
|
|
logger: NewLogger(io.Discard, "debug"),
|
|
}
|
|
start := time.Now()
|
|
completed := helm.sleepCtx(5 * time.Millisecond)
|
|
elapsed := time.Since(start)
|
|
if !completed {
|
|
t.Error("sleepCtx() = false without a context; want true")
|
|
}
|
|
if elapsed < 4*time.Millisecond {
|
|
t.Errorf("sleepCtx returned in %v without a context; expected to sleep ~5ms", elapsed)
|
|
}
|
|
}
|
|
|
|
// Test_Retry_AbortsOnCanceledContext verifies that retryRepoOp stops retrying
|
|
// once the runner's context is canceled, rather than spinning into a tight
|
|
// loop of rapid helm invocations.
|
|
func Test_Retry_AbortsOnCanceledContext(t *testing.T) {
|
|
withTestBackoff(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
runner := &mockRunner{failCount: 100} // always fails
|
|
helm := &execer{
|
|
options: HelmExecOptions{RepoRetry: 100},
|
|
version: semver.MustParse("v4.0.1"),
|
|
runner: &ShellRunner{Ctx: ctx},
|
|
logger: NewLogger(io.Discard, "debug"),
|
|
}
|
|
// Cancel deterministically inside the op after the first attempt runs, so
|
|
// the retry loop must bail out at sleepCtx — no timing-based flakiness.
|
|
calls := 0
|
|
_, err := helm.retryRepoOp("test", func() ([]byte, error) {
|
|
calls++
|
|
out, e := runner.Execute("helm", []string{"repo", "update"}, nil, false)
|
|
if calls == 1 {
|
|
cancel()
|
|
}
|
|
return out, e
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
// The context is canceled during the first attempt; sleepCtx must observe
|
|
// it and abort, so only the first attempt should run.
|
|
if calls > 2 {
|
|
t.Errorf("calls = %d after cancellation; expected <= 2 (no tight loop)", calls)
|
|
}
|
|
}
|
|
|
|
func Test_SyncRelease(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.SyncRelease(HelmContext{}, "release", "chart", "default", "--timeout 10", "--wait", "--wait-for-jobs")
|
|
expected := `Upgrading release=release, chart=chart, namespace=default
|
|
exec: helm --kubeconfig config --kube-context dev upgrade --install release chart --timeout 10 --wait --wait-for-jobs --history-max 0
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.SyncRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.SyncRelease(HelmContext{}, "release", "chart", "default")
|
|
expected = `Upgrading release=release, chart=chart, namespace=default
|
|
exec: helm --kubeconfig config --kube-context dev upgrade --install release chart --history-max 0
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.SyncRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.SyncRelease(HelmContext{}, "release", "https://example_user:example_password@repo.example.com/chart.tgz", "default")
|
|
expected = `Upgrading release=release, chart=https://example_user:xxxxx@repo.example.com/chart.tgz, namespace=default
|
|
exec: helm --kubeconfig config --kube-context dev upgrade --install release https://example_user:example_password@repo.example.com/chart.tgz --history-max 0
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.SyncRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_UpdateDeps(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.UpdateDeps("./chart/foo")
|
|
expected := `Updating dependency ./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.UpdateDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
helm.SetExtraArgs("--verify")
|
|
err = helm.UpdateDeps("./chart/foo")
|
|
// --verify is a dependency-specific flag and should be preserved
|
|
expected = `Updating dependency ./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency update ./chart/foo --verify
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.UpdateDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_BuildDeps(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm3Runner := mockRunner{output: []byte("v3.2.4+ge29ce2a")}
|
|
helm, err := New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.BuildDeps("foo", "./chart/foo", []string{"--skip-refresh"}...)
|
|
expected := `Building dependency release=foo, chart=./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --skip-refresh
|
|
v3.2.4+ge29ce2a
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.BuildDeps("foo", "./chart/foo")
|
|
expected = `Building dependency release=foo, chart=./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
|
|
v3.2.4+ge29ce2a
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
helm.SetExtraArgs("--verify")
|
|
err = helm.BuildDeps("foo", "./chart/foo", []string{"--skip-refresh"}...)
|
|
// --verify is a dependency-specific flag and should be preserved
|
|
expected = `Building dependency release=foo, chart=./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --skip-refresh --verify
|
|
v3.2.4+ge29ce2a
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94")}
|
|
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm2Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.BuildDeps("foo", "./chart/foo")
|
|
expected = `Building dependency release=foo, chart=./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
|
|
Client: v2.16.1+ge13bc94
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.BuildDeps()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test that --dry-run flag is filtered out (not supported by helm dependency build)
|
|
buffer.Reset()
|
|
helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")}
|
|
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
helm.SetExtraArgs("--dry-run=server")
|
|
err = helm.BuildDeps("foo", "./chart/foo")
|
|
expected = `Building dependency release=foo, chart=./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo
|
|
v3.2.4+ge29ce2a
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.BuildDeps() with --dry-run should filter it out\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
// Test that global flags (--debug) and dependency flags (--verify) are preserved,
|
|
// while template-specific flags (--dry-run) are filtered out
|
|
buffer.Reset()
|
|
helm3Runner = mockRunner{output: []byte("v3.2.4+ge29ce2a")}
|
|
helm, err = New("helm", HelmExecOptions{}, logger, "config", "dev", &helm3Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
helm.SetExtraArgs("--debug", "--dry-run=server", "--verify", "--wait")
|
|
err = helm.BuildDeps("foo", "./chart/foo")
|
|
expected = `Building dependency release=foo, chart=./chart/foo
|
|
exec: helm --kubeconfig config --kube-context dev dependency build ./chart/foo --debug --verify
|
|
v3.2.4+ge29ce2a
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.BuildDeps() should preserve global and dependency flags\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_DecryptSecret(t *testing.T) {
|
|
// Set secrets plugin version to 4.7.4
|
|
if err := os.Setenv("HELM_PLUGINS", "../../test/plugins/secrets/4.7.4"); err != nil {
|
|
t.Errorf("failed to set environment HELM_PLUGINS error: %s", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Unsetenv("HELM_PLUGINS"); err != nil {
|
|
t.Errorf("failed to unset environment HELM_PLUGINS error: %s", err)
|
|
}
|
|
}()
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
tmpFilePath := "path/to/temp/file"
|
|
helm.writeTempFile = func(content []byte) (string, error) {
|
|
return tmpFilePath, nil
|
|
}
|
|
|
|
_, err = helm.DecryptSecret(HelmContext{}, "secretName")
|
|
if err != nil {
|
|
t.Errorf("Error: %v", err)
|
|
}
|
|
cwd, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Errorf("Error: %v", err)
|
|
}
|
|
// Run again for caching
|
|
_, err = helm.DecryptSecret(HelmContext{}, "secretName")
|
|
|
|
expected := fmt.Sprintf(`Preparing to decrypt secret %v/secretName
|
|
Decrypting secret %s/secretName
|
|
exec: helm --kubeconfig config --kube-context dev secrets decrypt %s/secretName
|
|
Decrypted %s/secretName into %s
|
|
Preparing to decrypt secret %s/secretName
|
|
Found secret in cache %s/secretName
|
|
Decrypted %s/secretName into %s
|
|
`, cwd, cwd, cwd, cwd, tmpFilePath, cwd, cwd, cwd, tmpFilePath)
|
|
if err != nil {
|
|
if _, ok := err.(*os.PathError); ok {
|
|
} else {
|
|
t.Errorf("Error: %v", err)
|
|
}
|
|
}
|
|
if d := cmp.Diff(expected, buffer.String()); d != "" {
|
|
t.Errorf("helmexec.DecryptSecret(): want (-), got (+):\n%s", d)
|
|
}
|
|
}
|
|
|
|
func Test_DecryptSecretWithGotmpl(t *testing.T) {
|
|
// Set secrets plugin version to 4.7.4
|
|
if err := os.Setenv("HELM_PLUGINS", "../../test/plugins/secrets/4.7.4"); err != nil {
|
|
t.Errorf("failed to set environment HELM_PLUGINS error: %s", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Unsetenv("HELM_PLUGINS"); err != nil {
|
|
t.Errorf("failed to unset environment HELM_PLUGINS error: %s", err)
|
|
}
|
|
}()
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
tmpFilePath := "path/to/temp/file"
|
|
helm.writeTempFile = func(content []byte) (string, error) {
|
|
return tmpFilePath, nil
|
|
}
|
|
|
|
secretName := "secretName.yaml.gotmpl"
|
|
_, err = helm.DecryptSecret(HelmContext{}, secretName)
|
|
if err != nil {
|
|
t.Errorf("Error: %v", err)
|
|
}
|
|
cwd, err := filepath.Abs(".")
|
|
if err != nil {
|
|
t.Errorf("Error: %v", err)
|
|
}
|
|
|
|
expected := fmt.Sprintf(`Preparing to decrypt secret %v/secretName.yaml.gotmpl
|
|
Decrypting secret %s/secretName.yaml.gotmpl
|
|
exec: helm --kubeconfig config --kube-context dev secrets decrypt %s/secretName.yaml.gotmpl
|
|
Decrypted %s/secretName.yaml.gotmpl into %s
|
|
`, cwd, cwd, cwd, cwd, tmpFilePath)
|
|
if err != nil {
|
|
t.Errorf("Error: %v", err)
|
|
}
|
|
if d := cmp.Diff(expected, buffer.String()); d != "" {
|
|
t.Errorf("helmexec.DecryptSecret(): want (-), got (+):\n%s", d)
|
|
}
|
|
}
|
|
|
|
func Test_DiffRelease(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false, "--timeout 10", "--wait", "--wait-for-jobs")
|
|
expected := `Comparing release=release, chart=chart, namespace=default
|
|
|
|
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart --timeout 10 --wait --wait-for-jobs
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.DiffRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false)
|
|
expected = `Comparing release=release, chart=chart, namespace=default
|
|
|
|
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.DiffRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.DiffRelease(HelmContext{}, "release", "https://example_user:example_password@repo.example.com/chart.tgz", "default", false)
|
|
expected = `Comparing release=release, chart=https://example_user:xxxxx@repo.example.com/chart.tgz, namespace=default
|
|
|
|
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release https://example_user:example_password@repo.example.com/chart.tgz
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.DiffRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_DiffRelease_ColorFlagHelm4(t *testing.T) {
|
|
// Test that --color and --no-color flags are removed and HELM_DIFF_COLOR env var is set for Helm 4
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
|
|
// MockExecer creates a Helm 4 execer by default (returns v4.0.1)
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Verify it's Helm 4
|
|
if !helm.IsHelm4() {
|
|
t.Errorf("expected Helm 4, got version: %v", helm.GetVersion())
|
|
}
|
|
|
|
// Test with --color flag
|
|
buffer.Reset()
|
|
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false, "--color", "--context", "3")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
// The --color flag should be removed and --context should remain
|
|
expected := `Comparing release=release, chart=chart, namespace=default
|
|
|
|
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart --context 3
|
|
`
|
|
actual := buffer.String()
|
|
if actual != expected {
|
|
t.Errorf("helmexec.DiffRelease() with --color\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
|
|
// Verify --color flag was removed
|
|
if strings.Contains(actual, "--color") {
|
|
t.Errorf("--color flag should have been removed, but got: %v", actual)
|
|
}
|
|
|
|
// Test with --no-color flag
|
|
buffer.Reset()
|
|
err = helm.DiffRelease(HelmContext{}, "release", "chart", "default", false, "--no-color", "--context", "3")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
// The --no-color flag should be removed and --context should remain
|
|
expected = `Comparing release=release, chart=chart, namespace=default
|
|
|
|
exec: helm --kubeconfig config --kube-context dev diff upgrade --allow-unreleased release chart --context 3
|
|
`
|
|
actual = buffer.String()
|
|
if actual != expected {
|
|
t.Errorf("helmexec.DiffRelease() with --no-color\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
|
|
// Verify --no-color flag was removed
|
|
if strings.Contains(actual, "--no-color") {
|
|
t.Errorf("--no-color flag should have been removed, but got: %v", actual)
|
|
}
|
|
}
|
|
|
|
func Test_FilterColorFlagsForHelm4(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
inputFlags []string
|
|
expectedFlags []string
|
|
expectedEnvKey string
|
|
expectedEnvVal string
|
|
}{
|
|
{
|
|
name: "color flag",
|
|
inputFlags: []string{"--color", "--context", "3"},
|
|
expectedFlags: []string{"--context", "3"},
|
|
expectedEnvKey: "HELM_DIFF_COLOR",
|
|
expectedEnvVal: "true",
|
|
},
|
|
{
|
|
name: "no-color flag",
|
|
inputFlags: []string{"--no-color", "--context", "3"},
|
|
expectedFlags: []string{"--context", "3"},
|
|
expectedEnvKey: "HELM_DIFF_COLOR",
|
|
expectedEnvVal: "false",
|
|
},
|
|
{
|
|
name: "no color flags",
|
|
inputFlags: []string{"--context", "3", "--detailed-exitcode"},
|
|
expectedFlags: []string{"--context", "3", "--detailed-exitcode"},
|
|
expectedEnvKey: "",
|
|
expectedEnvVal: "",
|
|
},
|
|
{
|
|
name: "color flag with other flags",
|
|
inputFlags: []string{"--detailed-exitcode", "--color", "--suppress", "secret"},
|
|
expectedFlags: []string{"--detailed-exitcode", "--suppress", "secret"},
|
|
expectedEnvKey: "HELM_DIFF_COLOR",
|
|
expectedEnvVal: "true",
|
|
},
|
|
{
|
|
name: "both color and no-color flags (first wins with defensive check)",
|
|
inputFlags: []string{"--color", "--no-color", "--context", "3"},
|
|
expectedFlags: []string{"--context", "3"},
|
|
expectedEnvKey: "HELM_DIFF_COLOR",
|
|
expectedEnvVal: "true", // Changed: first flag wins due to defensive check
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
env := make(map[string]string)
|
|
actualFlags := helm.filterColorFlagsForHelm4(tt.inputFlags, env)
|
|
|
|
if !reflect.DeepEqual(actualFlags, tt.expectedFlags) {
|
|
t.Errorf("filterColorFlagsForHelm4() flags\nactual = %v\nexpect = %v", actualFlags, tt.expectedFlags)
|
|
}
|
|
|
|
if tt.expectedEnvKey != "" {
|
|
if env[tt.expectedEnvKey] != tt.expectedEnvVal {
|
|
t.Errorf("filterColorFlagsForHelm4() env[%v]\nactual = %v\nexpect = %v",
|
|
tt.expectedEnvKey, env[tt.expectedEnvKey], tt.expectedEnvVal)
|
|
}
|
|
} else if len(env) > 0 {
|
|
t.Errorf("filterColorFlagsForHelm4() expected no env vars, but got: %v", env)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_DeleteRelease(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.DeleteRelease(HelmContext{}, "release")
|
|
expected := `Deleting release
|
|
exec: helm --kubeconfig config --kube-context dev delete release
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.DeleteRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
func Test_DeleteRelease_Flags(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.DeleteRelease(HelmContext{}, "release", "--purge")
|
|
expected := `Deleting release
|
|
exec: helm --kubeconfig config --kube-context dev delete release --purge
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.DeleteRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_TestRelease(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.TestRelease(HelmContext{}, "release")
|
|
expected := `Testing release
|
|
exec: helm --kubeconfig config --kube-context dev test release
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.TestRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
func Test_TestRelease_Flags(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.TestRelease(HelmContext{}, "release", "--cleanup", "--timeout", "60")
|
|
expected := `Testing release
|
|
exec: helm --kubeconfig config --kube-context dev test release --cleanup --timeout 60
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.TestRelease()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_ReleaseStatus(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.ReleaseStatus(HelmContext{}, "myRelease")
|
|
expected := `Getting status myRelease
|
|
exec: helm --kubeconfig config --kube-context dev status myRelease
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.ReleaseStatus()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_exec(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "", "")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
env := map[string]string{}
|
|
_, err = helm.exec([]string{"version"}, env)
|
|
expected := `exec: helm version
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
helm, err = MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
ret, _ := helm.exec([]string{"diff"}, env)
|
|
if len(ret) != 0 {
|
|
t.Error("helmexec.exec() - expected empty return value")
|
|
}
|
|
|
|
buffer.Reset()
|
|
helm, err = MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
_, err = helm.exec([]string{"diff", "release", "chart", "--timeout 10", "--wait", "--wait-for-jobs"}, env)
|
|
expected = `exec: helm --kubeconfig config --kube-context dev diff release chart --timeout 10 --wait --wait-for-jobs
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
_, err = helm.exec([]string{"version"}, env)
|
|
expected = `exec: helm --kubeconfig config --kube-context dev version
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
helm.SetExtraArgs("foo")
|
|
_, err = helm.exec([]string{"version"}, env)
|
|
expected = `exec: helm --kubeconfig config --kube-context dev version foo
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
helm, err = MockExecer(logger, "", "")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
helm.SetHelmBinary("overwritten")
|
|
_, err = helm.exec([]string{"version"}, env)
|
|
expected = `exec: overwritten version
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.exec()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_Lint(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.Lint("release", "path/to/chart", "--values", "file.yml")
|
|
expected := `Linting release=release, chart=path/to/chart
|
|
exec: helm --kubeconfig config --kube-context dev lint path/to/chart --values file.yml
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.Lint()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_Fetch(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.Fetch("chart", "--version", "1.2.3", "--untar", "--untardir", "/tmp/dir")
|
|
expected := `Fetching chart
|
|
exec: helm --kubeconfig config --kube-context dev fetch chart --version 1.2.3 --untar --untardir /tmp/dir
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.Fetch()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.Fetch("https://example_user:example_password@repo.example.com/chart.tgz", "--version", "1.2.3", "--untar", "--untardir", "/tmp/dir")
|
|
expected = `Fetching https://example_user:xxxxx@repo.example.com/chart.tgz
|
|
exec: helm --kubeconfig config --kube-context dev fetch https://example_user:example_password@repo.example.com/chart.tgz --version 1.2.3 --untar --untardir /tmp/dir
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.Fetch()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_ChartPull(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
tests := []struct {
|
|
name string
|
|
helmBin string
|
|
helmVersion string
|
|
chartName string
|
|
chartPath string
|
|
chartFlags []string
|
|
listResult string
|
|
}{
|
|
{
|
|
name: "less then v3.7.0",
|
|
helmBin: "helm",
|
|
helmVersion: "v3.6.0",
|
|
chartName: "chart",
|
|
chartPath: "path1",
|
|
chartFlags: []string{"--untar", "--untardir", "/tmp/dir"},
|
|
listResult: `Pulling chart
|
|
exec: helm --kubeconfig config --kube-context dev chart pull chart
|
|
`,
|
|
},
|
|
{
|
|
name: "more then v3.7.0",
|
|
helmBin: "helm",
|
|
helmVersion: "v3.10.0",
|
|
chartName: "repo/helm-charts:0.14.0",
|
|
chartPath: "path1",
|
|
chartFlags: []string{"--untardir", "/tmp/dir", "--version", "0.14.0"},
|
|
listResult: `Pulling repo/helm-charts:0.14.0
|
|
exec: helm --kubeconfig config --kube-context dev pull oci://repo/helm-charts --destination path1 --untar --untardir /tmp/dir --version 0.14.0
|
|
`,
|
|
},
|
|
{
|
|
name: "more then v3.7.0 with rc",
|
|
helmBin: "helm",
|
|
helmVersion: "v3.14.0-rc.1+g69dcc92",
|
|
chartName: "repo/helm-charts",
|
|
chartPath: "path1",
|
|
chartFlags: []string{"--untardir", "/tmp/dir", "--devel"},
|
|
listResult: `Pulling repo/helm-charts
|
|
exec: helm --kubeconfig config --kube-context dev pull oci://repo/helm-charts --destination path1 --untar --untardir /tmp/dir --devel
|
|
`,
|
|
},
|
|
}
|
|
for i := range tests {
|
|
tt := tests[i]
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
buffer.Reset()
|
|
helm, err := New(tt.helmBin, HelmExecOptions{}, logger, "config", "dev", &mockRunner{output: []byte(tt.helmVersion)})
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.ChartPull(tt.chartName, tt.chartPath, tt.chartFlags...)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
isMatch, _ := regexp.MatchString(tt.listResult, buffer.String())
|
|
if !isMatch {
|
|
t.Errorf("helmexec.ChartPull()\nactual = %v\nexpect = %v", buffer.String(), tt.listResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ChartExport(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
tests := []struct {
|
|
name string
|
|
helmBin string
|
|
helmVersion string
|
|
chartName string
|
|
chartPath string
|
|
listResult string
|
|
expectedError string
|
|
}{
|
|
{
|
|
name: "",
|
|
helmBin: "helm",
|
|
helmVersion: "v3.6.0",
|
|
chartName: "chart",
|
|
chartPath: "path1",
|
|
listResult: `Exporting chart
|
|
exec: helm --kubeconfig config --kube-context dev chart export chart --destination path1
|
|
`,
|
|
expectedError: "",
|
|
},
|
|
}
|
|
for i := range tests {
|
|
tt := tests[i]
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
buffer.Reset()
|
|
helm := &execer{
|
|
helmBinary: tt.helmBin,
|
|
version: semver.MustParse(tt.helmVersion),
|
|
logger: logger,
|
|
kubeconfig: "config",
|
|
kubeContext: "dev",
|
|
runner: &mockRunner{},
|
|
}
|
|
err := helm.ChartExport(tt.chartName, tt.chartPath)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != tt.listResult {
|
|
t.Errorf("helmexec.ChartExport()\nactual = %v\nexpect = %v", buffer.String(), tt.listResult)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
var logLevelTests = map[string]string{
|
|
"debug": `Adding repo myRepo https://repo.example.com/
|
|
exec: helm repo add myRepo https://repo.example.com/ --force-update --username example_user --password-stdin
|
|
`,
|
|
"info": `Adding repo myRepo https://repo.example.com/
|
|
`,
|
|
"warn": ``,
|
|
}
|
|
|
|
func Test_LogLevels(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
for logLevel, expected := range logLevelTests {
|
|
buffer.Reset()
|
|
logger := NewLogger(&buffer, logLevel)
|
|
helm, err := MockExecer(logger, "", "")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.AddRepo("myRepo", "https://repo.example.com/", "", "", "", "example_user", "example_password", "", false, false)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
|
|
actual := buffer.String()
|
|
|
|
if strings.Contains(actual, "--password-stdin") {
|
|
expected = strings.Replace(expected, "--password example_password", "--password-stdin", 1)
|
|
}
|
|
|
|
if actual != expected {
|
|
t.Errorf("helmexec.AddRepo()\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func Test_mergeEnv(t *testing.T) {
|
|
actual := env2map(mergeEnv([]string{"A=1", "B=c=d", "E=2"}, map[string]string{"B": "3", "F": "4"}))
|
|
expected := map[string]string{"A": "1", "B": "3", "E": "2", "F": "4"}
|
|
if !reflect.DeepEqual(actual, expected) {
|
|
t.Errorf("mergeEnv()\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
}
|
|
|
|
func Test_Template(t *testing.T) {
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm, err := MockExecer(logger, "config", "dev")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
err = helm.TemplateRelease("release", "path/to/chart", "--values", "file.yml")
|
|
expected := `Templating release=release, chart=path/to/chart
|
|
exec: helm --kubeconfig config --kube-context dev template release path/to/chart --values file.yml
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.Template()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
|
|
buffer.Reset()
|
|
err = helm.TemplateRelease("release", "https://example_user:example_password@repo.example.com/chart.tgz", "--values", "file.yml")
|
|
expected = `Templating release=release, chart=https://example_user:xxxxx@repo.example.com/chart.tgz
|
|
exec: helm --kubeconfig config --kube-context dev template release https://example_user:example_password@repo.example.com/chart.tgz --values file.yml
|
|
`
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if buffer.String() != expected {
|
|
t.Errorf("helmexec.Template()\nactual = %v\nexpect = %v", buffer.String(), expected)
|
|
}
|
|
}
|
|
|
|
func Test_Template_PostRendererWithOutputDir(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
postRendererFlag string
|
|
}{
|
|
{"separate flags", "--post-renderer"},
|
|
{"combined flag", "--post-renderer=/bin/echo"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
|
|
// Use Helm 3 version for the version probe so the Helm 3 workaround is applied.
|
|
// The workaround is not needed for Helm 4, which natively applies --post-renderer to --output-dir output.
|
|
runner := &mockRunner{versionOutput: []byte("v3.20.0")}
|
|
helm, err := New("helm", HelmExecOptions{}, logger, "config", "dev", runner)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
runner.output = []byte("apiVersion: v1\nkind: Namespace\n")
|
|
|
|
var flags []string
|
|
if tt.postRendererFlag == "--post-renderer" {
|
|
flags = []string{"--post-renderer", "/bin/echo", "--output-dir", tmpDir, "--values", "file.yml"}
|
|
} else {
|
|
flags = []string{tt.postRendererFlag, "--output-dir", tmpDir, "--values", "file.yml"}
|
|
}
|
|
|
|
err = helm.TemplateRelease("myrelease", "path/to/chart", flags...)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
outputPath := filepath.Join(tmpDir, "templates", "myrelease.yaml")
|
|
data, err := os.ReadFile(outputPath)
|
|
if err != nil {
|
|
t.Fatalf("expected output file %s to exist: %v", outputPath, err)
|
|
}
|
|
|
|
expected := "apiVersion: v1\nkind: Namespace\n\n"
|
|
if string(data) != expected {
|
|
t.Errorf("output file content:\nactual=%q\nexpect=%q", string(data), expected)
|
|
}
|
|
|
|
outputLog := buffer.String()
|
|
if strings.Contains(outputLog, "--output-dir") {
|
|
t.Errorf("helm should NOT have been called with --output-dir, got: %s", outputLog)
|
|
}
|
|
if !strings.Contains(outputLog, "--post-renderer") {
|
|
t.Errorf("helm should have been called with --post-renderer, got: %s", outputLog)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_IsHelm3(t *testing.T) {
|
|
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
|
|
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if !helm.IsHelm3() {
|
|
t.Error("helmexec.IsHelm3() - Failed to detect Helm 3")
|
|
}
|
|
|
|
helm4Runner := mockRunner{output: []byte("v4.0.1+g12500dd\n")}
|
|
helm, err = New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm4Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if helm.IsHelm3() {
|
|
t.Error("helmexec.IsHelm3() - Detected Helm 3 with Helm 4 version")
|
|
}
|
|
}
|
|
|
|
func Test_IsHelm4(t *testing.T) {
|
|
helm3Runner := mockRunner{output: []byte("v3.0.0+ge29ce2a\n")}
|
|
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if helm.IsHelm4() {
|
|
t.Error("helmexec.IsHelm4() - Detected Helm 4 with Helm 3 version")
|
|
}
|
|
|
|
helm4Runner := mockRunner{output: []byte("v4.0.1+g12500dd\n")}
|
|
helm, err = New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm4Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if !helm.IsHelm4() {
|
|
t.Error("helmexec.IsHelm4() - Failed to detect Helm 4")
|
|
}
|
|
}
|
|
|
|
func Test_GetPluginVersion(t *testing.T) {
|
|
v3ExpectedVersion := "3.15.0"
|
|
v4ExpectedVersion := "4.7.4"
|
|
v3PluginDirPath := "../../test/plugins/secrets/3.15.0"
|
|
v4PluginDirPath := "../../test/plugins/secrets/4.7.4"
|
|
|
|
v3SecretPluginVersion, err := GetPluginVersion("secrets", v3PluginDirPath)
|
|
require.NoError(t, err)
|
|
|
|
if v3SecretPluginVersion.String() != v3ExpectedVersion {
|
|
t.Errorf("secrets v3 plugin version is %v, expected %v", v3SecretPluginVersion.String(), v3ExpectedVersion)
|
|
}
|
|
|
|
v4SecretPluginVersion, err := GetPluginVersion("secrets", v4PluginDirPath)
|
|
require.NoError(t, err)
|
|
if v4SecretPluginVersion.String() != v4ExpectedVersion {
|
|
t.Errorf("secrets v4 plugin version is %v, expected %v", v4SecretPluginVersion.String(), v4ExpectedVersion)
|
|
}
|
|
}
|
|
|
|
func Test_GetPluginVersion_XDGPaths(t *testing.T) {
|
|
v3ExpectedVersion := "3.15.0"
|
|
v4ExpectedVersion := "4.7.4"
|
|
v3PluginDirPath := "../../test/plugins/secrets/3.15.0"
|
|
v4PluginDirPath := "../../test/plugins/secrets/4.7.4"
|
|
|
|
sep := string(os.PathListSeparator)
|
|
xdgPaths := "nonexistent/path" + sep + v3PluginDirPath + sep + "another/nonexistent"
|
|
|
|
pluginVersion, err := GetPluginVersion("secrets", xdgPaths)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, v3ExpectedVersion, pluginVersion.String())
|
|
|
|
xdgPathsV4 := v4PluginDirPath + sep + "nonexistent/path"
|
|
pluginVersion, err = GetPluginVersion("secrets", xdgPathsV4)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, v4ExpectedVersion, pluginVersion.String())
|
|
|
|
_, err = GetPluginVersion("nonexistent-plugin", xdgPaths)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "plugin nonexistent-plugin not installed")
|
|
}
|
|
|
|
func Test_GetPluginVersion_Symlink(t *testing.T) {
|
|
// Simulate a nix/devbox environment where the plugin directory is a symlink
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a real plugin directory with plugin.yaml
|
|
realPluginDir := filepath.Join(tmpDir, "real", "helm-diff")
|
|
require.NoError(t, os.MkdirAll(realPluginDir, 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(realPluginDir, "plugin.yaml"), []byte(`name: "diff"
|
|
version: "3.12.0"
|
|
`), 0o644))
|
|
|
|
// Create a plugins directory where helm-diff is a symlink (like nix/devbox)
|
|
symlinkPluginsDir := filepath.Join(tmpDir, "plugins")
|
|
require.NoError(t, os.MkdirAll(symlinkPluginsDir, 0o755))
|
|
require.NoError(t, os.Symlink(realPluginDir, filepath.Join(symlinkPluginsDir, "helm-diff")))
|
|
|
|
version, err := GetPluginVersion("diff", symlinkPluginsDir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "3.12.0", version.String())
|
|
}
|
|
|
|
func Test_GetVersion(t *testing.T) {
|
|
helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94\n")}
|
|
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm2Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
ver := helm.GetVersion()
|
|
if ver.Major != 2 || ver.Minor != 16 || ver.Patch != 1 {
|
|
t.Errorf("helmexec.GetVersion - did not detect correct Helm2 version; it was: %+v", ver)
|
|
}
|
|
|
|
helm3Runner := mockRunner{output: []byte("v3.2.4+ge29ce2a\n")}
|
|
helm, err = New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm3Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
ver = helm.GetVersion()
|
|
if ver.Major != 3 || ver.Minor != 2 || ver.Patch != 4 {
|
|
t.Errorf("helmexec.GetVersion - did not detect correct Helm3 version; it was: %+v", ver)
|
|
}
|
|
}
|
|
|
|
func Test_IsVersionAtLeast(t *testing.T) {
|
|
helm2Runner := mockRunner{output: []byte("Client: v2.16.1+ge13bc94\n")}
|
|
helm, err := New("helm", HelmExecOptions{}, NewLogger(os.Stdout, "info"), "", "dev", &helm2Runner)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if !helm.IsVersionAtLeast("2.1.0") {
|
|
t.Error("helmexec.IsVersionAtLeast - 2.16.1 not atleast 2.1")
|
|
}
|
|
|
|
if helm.IsVersionAtLeast("2.19.0") {
|
|
t.Error("helmexec.IsVersionAtLeast - 2.16.1 is atleast 2.19")
|
|
}
|
|
|
|
if helm.IsVersionAtLeast("3.2.0") {
|
|
t.Error("helmexec.IsVersionAtLeast - 2.16.1 is atleast 3.2")
|
|
}
|
|
}
|
|
|
|
func Test_resolveOciChart(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
chartPath string
|
|
ociChartURL string
|
|
ociChartTag string
|
|
}{
|
|
{
|
|
name: "normal",
|
|
chartPath: "chart/nginx:v1",
|
|
ociChartURL: "oci://chart/nginx",
|
|
ociChartTag: "v1",
|
|
},
|
|
{
|
|
name: "contains the port",
|
|
chartPath: "chart:5000/nginx:v1",
|
|
ociChartURL: "oci://chart:5000/nginx",
|
|
ociChartTag: "v1",
|
|
},
|
|
{
|
|
name: "no tag",
|
|
chartPath: "chart:5000/nginx",
|
|
ociChartURL: "oci://chart:5000/nginx",
|
|
ociChartTag: "",
|
|
},
|
|
{
|
|
name: "digest only",
|
|
chartPath: "ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
|
|
ociChartURL: "oci://ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
|
|
ociChartTag: "",
|
|
},
|
|
{
|
|
name: "version and digest",
|
|
chartPath: "ghcr.io/nginxinc/charts/nginx-ingress:2.0.0@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
|
|
ociChartURL: "oci://ghcr.io/nginxinc/charts/nginx-ingress@sha256:87ad282a8e7cc31913ce0543de2933ddb3f3eba80d6e5285f33b62ed720fc085",
|
|
ociChartTag: "2.0.0",
|
|
},
|
|
{
|
|
name: "port with digest",
|
|
chartPath: "registry:5000/chart@sha256:abc123",
|
|
ociChartURL: "oci://registry:5000/chart@sha256:abc123",
|
|
ociChartTag: "",
|
|
},
|
|
{
|
|
name: "port with version and digest",
|
|
chartPath: "registry:5000/chart:1.0.0@sha256:abc123",
|
|
ociChartURL: "oci://registry:5000/chart@sha256:abc123",
|
|
ociChartTag: "1.0.0",
|
|
},
|
|
}
|
|
for i := range tests {
|
|
tt := tests[i]
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
url, tag := resolveOciChart(tt.chartPath)
|
|
if tt.ociChartURL != url || tt.ociChartTag != tag {
|
|
actual := fmt.Sprintf("ociChartURL->%s ociChartTag->%s", url, tag)
|
|
expected := fmt.Sprintf("ociChartURL->%s ociChartTag->%s", tt.ociChartURL, tt.ociChartTag)
|
|
t.Errorf("resolveOciChart()\nactual = %v\nexpect = %v", actual, expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ShowChart(t *testing.T) {
|
|
showChartRunner := mockRunner{output: []byte("name: my-chart\nversion: 3.2.0\n")}
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse("3.3.2"),
|
|
logger: NewLogger(os.Stdout, "info"),
|
|
kubeContext: "dev",
|
|
runner: &showChartRunner,
|
|
}
|
|
|
|
metadata, err := helm.ShowChart("my-chart")
|
|
if err != nil {
|
|
t.Errorf("helmexec.ShowChart() - unexpected error: %v", err)
|
|
}
|
|
if metadata.Name != "my-chart" {
|
|
t.Errorf("helmexec.ShowChart() - expected chart name was %s, received: %s", "my-chart", metadata.Name)
|
|
}
|
|
if metadata.Version != "3.2.0" {
|
|
t.Errorf("helmexec.ShowChart() - expected chart version was %s, received: %s", "3.2.0", metadata.Version)
|
|
}
|
|
}
|
|
|
|
func Test_SetDisableForceUpdate(t *testing.T) {
|
|
helm, err := MockExecer(NewLogger(os.Stdout, "info"), "config", "dev")
|
|
if err != nil {
|
|
t.Error(err)
|
|
}
|
|
if helm.options.DisableForceUpdate {
|
|
t.Error("helmexec.options.DisableForceUpdate should not be enabled by default")
|
|
}
|
|
helm.SetDisableForceUpdate(true)
|
|
if !helm.options.DisableForceUpdate {
|
|
t.Errorf("helmexec.SetDisableForceUpdate() - actual = %t expect = true", helm.options.DisableForceUpdate)
|
|
}
|
|
}
|
|
|
|
func TestParseHelmVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
want *semver.Version
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "helm 2",
|
|
version: "Client: v2.16.1+ge13bc94\n",
|
|
want: semver.MustParse("v2.16.1+ge13bc94"),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "helm 3",
|
|
version: "Client: v3.2.4+ge29ce2a\n",
|
|
want: semver.MustParse("v3.2.4+ge29ce2a"),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "helm 3 with os arch and build info",
|
|
version: "Client v3.7.1+7.el8+g8f33223\n",
|
|
want: semver.MustParse("v3.7.1+7.el8"),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty version",
|
|
version: "",
|
|
want: nil,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "invalid version",
|
|
version: "oooooo",
|
|
want: nil,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "version without v prefix",
|
|
version: "3.2.4",
|
|
want: semver.MustParse("v3.2.4"),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "version without v prefix with build info",
|
|
version: "3.2.4+ge29ce2a",
|
|
want: semver.MustParse("v3.2.4+ge29ce2a"),
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "version without v prefix with spaces",
|
|
version: " 3.2.4 ",
|
|
want: semver.MustParse("v3.2.4"),
|
|
wantErr: false,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := parseHelmVersion(tt.version)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseHelmVersion() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("parseHelmVersion() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_helmSecretsRequiresSplitInstall(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
version string
|
|
want bool
|
|
}{
|
|
{name: "below threshold", version: "v4.6.9", want: false},
|
|
{name: "exactly at threshold", version: "v4.7.0", want: true},
|
|
{name: "above threshold single digit minor", version: "v4.8.0", want: true},
|
|
{name: "above threshold double digit minor (v4.10.0+)", version: "v4.10.0", want: true},
|
|
{name: "pre-release below threshold", version: "v4.7.0-beta.1", want: false},
|
|
{name: "invalid version string", version: "not-a-version", want: false},
|
|
{name: "empty string", version: "", want: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := helmSecretsRequiresSplitInstall(tt.version)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
type funcRunner struct {
|
|
execute func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error)
|
|
}
|
|
|
|
func (r *funcRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
|
|
return r.execute(cmd, args, env, false)
|
|
}
|
|
|
|
func (r *funcRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
return r.execute(cmd, args, env, enableLiveOutput)
|
|
}
|
|
|
|
func Test_UpdatePlugin_Helm4SecretsUsesUninstallReinstall(t *testing.T) {
|
|
var calledArgs [][]string
|
|
runner := &funcRunner{
|
|
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
calledArgs = append(calledArgs, append([]string(nil), args...))
|
|
return []byte{}, nil
|
|
},
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse("4.0.0"),
|
|
logger: logger,
|
|
kubeconfig: "config",
|
|
kubeContext: "dev",
|
|
runner: runner,
|
|
}
|
|
|
|
err := helm.UpdatePlugin("secrets", "https://github.com/jkroepke/helm-secrets", "v4.7.0")
|
|
require.NoError(t, err)
|
|
|
|
// Verify that "plugin update" was NOT called (the Helm 4 secrets path should skip it).
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+1 < len(args) && args[i+1] == "update" {
|
|
t.Errorf("expected 'plugin update' to not be called for Helm 4 secrets, but it was: %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Verify that uninstall was called for all three split plugins.
|
|
checkUninstall := func(name string) {
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+2 < len(args) && args[i+1] == "uninstall" && args[i+2] == name {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
t.Errorf("expected 'plugin uninstall %s' to be called", name)
|
|
}
|
|
checkUninstall("secrets")
|
|
checkUninstall("secrets-getter")
|
|
checkUninstall("secrets-post-renderer")
|
|
|
|
// Verify that install was called for all three split plugin tarballs.
|
|
checkInstall := func(urlSubstring string) {
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+2 < len(args) && args[i+1] == installCmd && strings.Contains(args[i+2], urlSubstring) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
t.Errorf("expected 'plugin install' for %q to be called", urlSubstring)
|
|
}
|
|
checkInstall("secrets-4.7.0.tgz")
|
|
checkInstall("secrets-getter-4.7.0.tgz")
|
|
checkInstall("secrets-post-renderer-4.7.0.tgz")
|
|
}
|
|
|
|
// Test_UpdatePlugin_GeneralPathUsesUninstallReinstall verifies that for a regular
|
|
// plugin, UpdatePlugin does NOT rely on the unreliable `helm plugin update`
|
|
// command. Instead it must uninstall the existing plugin and reinstall the exact
|
|
// pinned version. See issues #2726 and #2548: `helm plugin update` reports
|
|
// success but leaves `helm plugin list` showing the old version because it
|
|
// re-installs from the cached source without the --version flag.
|
|
func Test_UpdatePlugin_GeneralPathUsesUninstallReinstall(t *testing.T) {
|
|
var calledArgs [][]string
|
|
runner := &funcRunner{
|
|
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
calledArgs = append(calledArgs, append([]string(nil), args...))
|
|
return []byte{}, nil
|
|
},
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse("3.16.4"),
|
|
logger: logger,
|
|
runner: runner,
|
|
}
|
|
|
|
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
|
|
require.NoError(t, err)
|
|
|
|
// `plugin update` must never be used: it does not honor --version and silently
|
|
// leaves the old version installed.
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+1 < len(args) && args[i+1] == "update" {
|
|
t.Errorf("expected 'plugin update' to not be called, but it was: %v", args)
|
|
}
|
|
}
|
|
}
|
|
|
|
// `plugin uninstall diff` must be called to clear the stale version.
|
|
uninstalled := false
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+2 < len(args) && args[i+1] == "uninstall" && args[i+2] == "diff" {
|
|
uninstalled = true
|
|
}
|
|
}
|
|
}
|
|
require.True(t, uninstalled, "expected 'plugin uninstall diff' to be called")
|
|
|
|
// `plugin install <repo> --version <pinned>` must be called with the exact
|
|
// requested version so the installed plugin is updated to it.
|
|
installed := false
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+1 < len(args) && args[i+1] == installCmd {
|
|
hasRepo := false
|
|
hasVersion := false
|
|
for _, arg := range args[i+2:] {
|
|
if arg == "https://github.com/databus23/helm-diff" {
|
|
hasRepo = true
|
|
}
|
|
if arg == "v3.15.10" {
|
|
hasVersion = true
|
|
}
|
|
}
|
|
if hasRepo && hasVersion {
|
|
installed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
require.True(t, installed, "expected 'plugin install' with the pinned version to be called")
|
|
}
|
|
|
|
// Test_UpdatePlugin_NotFoundUninstallProceedsToInstall ensures that when the
|
|
// plugin is already absent (helm reports its "plugin not found" message),
|
|
// UpdatePlugin still proceeds to install the pinned version rather than treating
|
|
// it as an error. Both helm major formats are covered.
|
|
func Test_UpdatePlugin_NotFoundUninstallProceedsToInstall(t *testing.T) {
|
|
// Helm 4 reports "plugin: <name> not found"; Helm 3 reports "Plugin: <name> not found".
|
|
for _, tc := range []struct {
|
|
name string
|
|
version string
|
|
msg string
|
|
}{
|
|
{name: "helm4", version: "4.2.4", msg: "plugin: diff not found"},
|
|
{name: "helm3", version: "3.16.4", msg: "Plugin: diff not found"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var calledArgs [][]string
|
|
runner := &funcRunner{
|
|
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
calledArgs = append(calledArgs, append([]string(nil), args...))
|
|
if len(args) >= 2 && args[0] == pluginCmd && args[1] == "uninstall" {
|
|
return nil, ExitError{Message: tc.msg, Code: 1}
|
|
}
|
|
return []byte{}, nil
|
|
},
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse(tc.version),
|
|
logger: logger,
|
|
runner: runner,
|
|
}
|
|
|
|
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
|
|
require.NoError(t, err, "a plugin-absent uninstall error should be ignored and install should proceed")
|
|
|
|
// install with the pinned version must still be attempted
|
|
installed := false
|
|
for _, args := range calledArgs {
|
|
for i, a := range args {
|
|
if a == pluginCmd && i+1 < len(args) && args[i+1] == installCmd {
|
|
for _, arg := range args[i+2:] {
|
|
if arg == "v3.15.10" {
|
|
installed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
require.True(t, installed, "expected install to proceed after a plugin-absent uninstall")
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test_UpdatePlugin_RealUninstallFailureReturnsError ensures that a genuine
|
|
// uninstall failure (permissions, broken Helm, etc.) is surfaced instead of
|
|
// being ignored, which would otherwise mask the root cause behind a confusing
|
|
// "plugin already exists" error from the subsequent install.
|
|
func Test_UpdatePlugin_RealUninstallFailureReturnsError(t *testing.T) {
|
|
installCalled := false
|
|
runner := &funcRunner{
|
|
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
if len(args) >= 2 && args[0] == pluginCmd && args[1] == "uninstall" {
|
|
return nil, ExitError{Message: "permission denied", Code: 1}
|
|
}
|
|
if len(args) >= 2 && args[0] == pluginCmd && args[1] == installCmd {
|
|
installCalled = true
|
|
}
|
|
return []byte{}, nil
|
|
},
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse("3.16.4"),
|
|
logger: logger,
|
|
runner: runner,
|
|
}
|
|
|
|
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
|
|
require.Error(t, err, "a real uninstall failure should be returned")
|
|
assert.Contains(t, err.Error(), "uninstall")
|
|
assert.False(t, installCalled, "install must not be attempted after a real uninstall failure")
|
|
}
|
|
|
|
// Test_UpdatePlugin_ExecutableNotFoundUninstallErrorIsNotSwallowed guards against
|
|
// an overly broad "not found" check: when helm itself is missing the runner
|
|
// surfaces an error containing "executable file not found", which must NOT be
|
|
// mistaken for an absent plugin (otherwise UpdatePlugin would silently log and
|
|
// proceed to install, masking the real problem). Only the helm-specific
|
|
// "plugin: <name> not found" message is tolerated.
|
|
func Test_UpdatePlugin_ExecutableNotFoundUninstallErrorIsNotSwallowed(t *testing.T) {
|
|
installCalled := false
|
|
runner := &funcRunner{
|
|
execute: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
|
|
if len(args) >= 2 && args[0] == pluginCmd && args[1] == "uninstall" {
|
|
// Mimics helmfile's ShellRunner when the helm binary is absent.
|
|
return nil, fmt.Errorf("unexpected error: exec: %q: executable file not found in $PATH", cmd)
|
|
}
|
|
if len(args) >= 2 && args[0] == pluginCmd && args[1] == installCmd {
|
|
installCalled = true
|
|
}
|
|
return []byte{}, nil
|
|
},
|
|
}
|
|
|
|
var buffer bytes.Buffer
|
|
logger := NewLogger(&buffer, "debug")
|
|
helm := &execer{
|
|
helmBinary: "helm",
|
|
version: semver.MustParse("3.16.4"),
|
|
logger: logger,
|
|
runner: runner,
|
|
}
|
|
|
|
err := helm.UpdatePlugin("diff", "https://github.com/databus23/helm-diff", "v3.15.10")
|
|
require.Error(t, err, "a missing-binary uninstall error must be returned, not swallowed")
|
|
assert.Contains(t, err.Error(), "executable file not found")
|
|
assert.False(t, installCalled, "install must not be attempted when the helm binary is missing")
|
|
}
|
|
|
|
func Test_dedupeWroteLines(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{
|
|
name: "empty",
|
|
in: "",
|
|
want: "",
|
|
},
|
|
{
|
|
name: "no wrote lines untouched",
|
|
in: "Found 25 resources in manifest for release system\n",
|
|
want: "Found 25 resources in manifest for release system\n",
|
|
},
|
|
{
|
|
name: "collapses duplicate wrote lines preserving first-seen order",
|
|
in: "wrote /tmp/a/service_account.yaml\n" +
|
|
"wrote /tmp/a/service_account.yaml\n" +
|
|
"wrote /tmp/a/secrets.yaml\n" +
|
|
"wrote /tmp/a/service_account.yaml\n" +
|
|
"wrote /tmp/a/secrets.yaml\n" +
|
|
"Found 25 resources in manifest for release system",
|
|
want: "wrote /tmp/a/service_account.yaml\n" +
|
|
"wrote /tmp/a/secrets.yaml\n" +
|
|
"Found 25 resources in manifest for release system",
|
|
},
|
|
{
|
|
name: "non-wrote duplicates kept",
|
|
in: "hello\nhello\n",
|
|
want: "hello\nhello\n",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := string(dedupeWroteLines([]byte(c.in)))
|
|
if got != c.want {
|
|
t.Errorf("dedupeWroteLines() = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|