Compare commits

...
Author SHA1 Message Date
Spicy Shark dfce9cf570 fix: honor release-level syncReleaseLabels false (#2839)
* fix: honor release-level syncReleaseLabels false

An explicit release syncReleaseLabels false now prevents helmDefaults from
adding --labels. Unset releases still inherit the default, and the
command-line flag still takes precedence.

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>

* test: set HelmDefaults through the promoted field

Applies the go fix modernizer rewrite the lint job checks for.

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>

---------

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>
2026-10-21 18:04:32 +08:00
Spicy Shark 0d5311ba4d fix: honor release-level takeOwnership false over helmDefaults (#2838)
* fix: honor release-level takeOwnership false over helmDefaults

An explicit release takeOwnership false now prevents helmDefaults from
adding --take-ownership to upgrades and helmfile diff. Unset releases
still inherit the default, and the command-line flag still takes
precedence.

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>

* test: set HelmDefaults through the promoted field

Applies the go fix modernizer rewrite the lint job checks for.

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>

---------

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>
2026-10-21 18:04:03 +08:00
Spicy Shark 54c5944fce fix: honor release-level skipSchemaValidation false (#2837)
An explicit release value of false now keeps helmDefaults from adding
--skip-schema-validation. The command-line flag still applies to every
release.

Signed-off-by: spicysharkco <321699413+spicysharkco@users.noreply.github.com>
2026-10-21 09:18:50 +08:00
dependabot[bot] d5a354144a build(deps): bump github.com/gookit/color from 1.6.2 to 1.6.3 (#2836)
Bumps [github.com/gookit/color](https://github.com/gookit/color) from 1.6.2 to 1.6.3.
- [Release notes](https://github.com/gookit/color/releases)
- [Commits](https://github.com/gookit/color/compare/v1.6.2...v1.6.3)

---
updated-dependencies:
- dependency-name: github.com/gookit/color
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 08:37:02 +08:00
dependabot[bot] 5d4db7ab89 build(deps): bump github.com/go-test/deep from 1.1.1 to 1.1.2 (#2835)
Bumps [github.com/go-test/deep](https://github.com/go-test/deep) from 1.1.1 to 1.1.2.
- [Release notes](https://github.com/go-test/deep/releases)
- [Changelog](https://github.com/go-test/deep/blob/main/CHANGES.md)
- [Commits](https://github.com/go-test/deep/compare/v1.1.1...v1.1.2)

---
updated-dependencies:
- dependency-name: github.com/go-test/deep
  dependency-version: 1.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 08:36:20 +08:00
yxxhero 254b2aa617 fix: propagate --oci-plain-http to chartify for all chart-preparing commands (#2826)
* fix: propagate --oci-plain-http to chartify for all chart-preparing commands

The global --oci-plain-http flag was only wired into ChartPrepareOptions
for the template command. For apply, sync, diff, lint, unittest, fetch
(and destroy/test, which share the same chartify path), the flag never
reached chartify, so its internal 'helm dependency up' against OCI chart
dependencies was issued without --plain-http and failed against
plain-HTTP OCI registries (the per-repository plainHttp field only
covers the helm pull of the release chart, not chartify's dependency
update).

Add HelmOCIPlainHTTP: a.HelmOCIPlainHTTP to the ChartPrepareOptions of
these commands, mirroring Template(), and add a regression test that
fetches a release with an adhoc OCI dependency from a plain-HTTP local
registry.

Fixes #2824

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

* test: use lint (not fetch) to exercise chartify OCI plain-http path

The pull/fetch command skips chartify (except for local dirs without
Chart.yaml) and runs helmexec.BuildDeps instead, which already handles
--plain-http at the exec layer. lint goes through chartify like
template/apply/sync/diff do, and needs no cluster, so it reproduces
issue #2824: before the fix, chartify's 'helm dependency up' on the
adhoc OCI dependency fails against the plain-HTTP registry on Helm 4.

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

* test: align lint snapshot goldens with actual helm output

Fix the Helm 3 golden with the exact output captured from CI (helm lint
prints the chartified chart path and extra blank lines). The Helm 4
golden follows the same shape plus the Pulled/Digest lines emitted by
helm dependency up.

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

* test: add fetch regression case exercising chartify OCI plain-http path

helmfile fetch only enters chartification when the chart is a local
directory without Chart.yaml (NeedsChartifyForLocalDir) — a packaged or
regular local chart goes through helm dependency build instead, which
already handles --plain-http at the helmexec layer. The new case uses a
manifest-only chart directory plus an adhoc OCI dependency, so chartify's
own 'helm dependency up' must receive the propagated --oci-plain-http on
Helm 4, directly protecting the Fetch() wiring added in this PR.

To reference the case-local fixture, the snapshot harness now rewrites
'./chart/' in inputs to the chart directory shipped next to the case
(mirroring the existing '../../charts/' rewrite). The fixture cannot
live in the shared testdata/charts directory because every chart there
is helm-packaged and pushed to the test registry.

Addresses the review finding that only the Lint assignment was covered.

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

* test: align fetch goldens with actual helm dep build output

For a chartified manifest-only chart, chartify's ReplaceWithRendered
strips the Chart.yaml dependencies field and removes charts/ after
rendering the adhoc dependency into templates/. The follow-up
`helm dependency build` therefore finds no declared dependencies and
returns silently (helm3/helm4 Manager short-circuit), so the captured
output is just the "Building dependency" log line — confirmed by the
Helm 3 CI diff. Helm 4.3.0's Manager has the identical early-return
path, so its golden matches.

The regression signal remains the command's exit code: without the
propagated --oci-plain-http, chartify's `helm dependency up` fails on
Helm 4 and the whole fetch errors out.

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

---------

Signed-off-by: yxxhero <yunxuan8603@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
2026-10-16 13:37:47 +08:00
yxxhero b2644c6ec2 chore: bump Helm to v3.22.1 and v4.3.1 (#2834)
- Update CI test matrix from v3.22.0/v4.3.0 to v3.22.1/v4.3.1
- Update Helm v4.3.1 SHA256 checksums in Dockerfiles
- Update go.mod dependencies (helm.sh/helm/v3 v3.22.1, helm.sh/helm/v4 v4.3.1)
- Update HelmRecommendedVersion to v4.3.1
- Pin controller-runtime to v0.24.1: v0.25.x needs client-go >= v0.36
  (toolscache.DoneChecker), conflicting with the k8s.io/api v0.35.4 pin
  required by kubedog's flagger dependency

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-10-16 13:37:34 +08:00
Daniel Pintoandyxxhero 8fc23a4d80 fix: skip recreatePods on Helm 4, which removed --recreate-pods (#2832)
* fix: skip recreatePods on Helm 4, which removed --recreate-pods

- Helm 4 removed `--recreate-pods` (helm/helm#31023) and fails with "unknown flag", so a release
  with `recreatePods: true` broke `sync` and `apply`. On Helm 4 the flag is now left out with a
  warning; Helm 3 still receives it.
- The upgrade flag tests gain Helm 4 cases, and the existing ones are pinned to Helm 3 so they no
  longer depend on the installed helm binary.
- A test checks that the warning names the release on Helm 4 and is not logged on Helm 3.
- The configuration docs note that recreatePods is ignored on Helm 4.

Signed-off-by: Cayan <1619617+Cayan@users.noreply.github.com>

* refactor: flatten recreatePods flag logic in flagsForUpgrade

Drop the nested if-in-if and the st.logger nil-guard: no other log call in
state.go nil-checks the logger (e.g. the --color Helm 4 skip). Compute
recreatePodsEnabled and branch via a tagless switch, matching the switch
style already used elsewhere in state.go: warn on Helm 4, append
--recreate-pods otherwise. The flagsForUpgrade table runner now uses the
shared discard logger so the Helm 4 recreate-pods cases log safely.

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

---------

Signed-off-by: Cayan <1619617+Cayan@users.noreply.github.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-10-16 09:23:47 +08:00
dependabot[bot] 5c225fd200 build(deps): bump github.com/gookit/color from 1.6.1 to 1.6.2 (#2833)
Bumps [github.com/gookit/color](https://github.com/gookit/color) from 1.6.1 to 1.6.2.
- [Release notes](https://github.com/gookit/color/releases)
- [Commits](https://github.com/gookit/color/compare/v1.6.1...v1.6.2)

---
updated-dependencies:
- dependency-name: github.com/gookit/color
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-16 07:16:30 +08:00
dependabot[bot] b71d27e85b build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.33.6 to 1.33.9 (#2831)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.6 to 1.33.9.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.6...config/v1.33.9)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 08:03:01 +08:00
dependabot[bot] 735c484ff5 build(deps): bump golang.org/x/term from 0.46.0 to 0.47.0 (#2830)
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.46.0 to 0.47.0.
- [Commits](https://github.com/golang/term/compare/v0.46.0...v0.47.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 07:54:11 +08:00
dependabot[bot] 706ceda804 build(deps): bump github.com/mattn/go-runewidth from 0.0.30 to 0.0.31 (#2829)
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.30 to 0.0.31.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.30...v0.0.31)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.31
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 07:54:05 +08:00
dependabot[bot] fa878f5ebb build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.114.0 to 1.114.3 (#2828)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.114.0 to 1.114.3.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.114.0...service/s3/v1.114.3)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.114.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-15 07:53:59 +08:00
dependabot[bot] eeb913472a build(deps): bump go.opentelemetry.io/contrib/exporters/autoexport from 0.71.0 to 0.72.0 (#2823)
build(deps): bump go.opentelemetry.io/contrib/exporters/autoexport

Bumps [go.opentelemetry.io/contrib/exporters/autoexport](https://github.com/open-telemetry/opentelemetry-go-contrib) from 0.71.0 to 0.72.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.71.0...zpages/v0.72.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/contrib/exporters/autoexport
  dependency-version: 0.72.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 11:34:04 +08:00
dependabot[bot] e088d125f6 build(deps): bump go.opentelemetry.io/proto/otlp from 1.11.0 to 1.11.1 (#2822)
Bumps [go.opentelemetry.io/proto/otlp](https://github.com/open-telemetry/opentelemetry-proto-go) from 1.11.0 to 1.11.1.
- [Release notes](https://github.com/open-telemetry/opentelemetry-proto-go/releases)
- [Commits](https://github.com/open-telemetry/opentelemetry-proto-go/compare/v1.11.0...v1.11.1)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/proto/otlp
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 11:31:30 +08:00
dependabot[bot] b7f71a0ce3 build(deps): bump github.com/hashicorp/go-getter/v2 from 2.2.4 to 2.2.5 (#2821)
Bumps [github.com/hashicorp/go-getter/v2](https://github.com/hashicorp/go-getter) from 2.2.4 to 2.2.5.
- [Release notes](https://github.com/hashicorp/go-getter/releases)
- [Changelog](https://github.com/hashicorp/go-getter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hashicorp/go-getter/compare/v2.2.4...v2.2.5)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/go-getter/v2
  dependency-version: 2.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-11 11:31:25 +08:00
pznamenskyandyxxhero 12fb6887c0 add trackLogsInterval option (#2825)
* add trackLogsInterval option

Signed-off-by: pznamensky <kompastver@gmail.com>

* test: add coverage for trackLogsInterval option

The trackLogsInterval feature shipped without tests. Add coverage for:

- config getter: zero when --track-logs-interval is not supplied, explicit
  value (including an explicit default) when supplied, and the nil-Cmd
  fallback for non-cobra callers (pkg/config/apply_test.go, sync_test.go)
- buildReleaseTracker resolution: explicit CLI interval overrides the
  release setting, release setting used when the flag is absent, 10s
  default preserved when both are unset (pkg/state/helmx_test.go)
- release-level minimum validation in ExecuteTemplates: below/zero/
  negative rejected, 1s and above accepted (pkg/state/state_exec_tmpl_test.go)

Also add the missing blank line after syncImpl.Cmd in cmd/sync.go for
consistency with cmd/apply.go.

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

---------

Signed-off-by: pznamensky <kompastver@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-10-10 14:13:10 +08:00
dependabot[bot] 3216c02743 build(deps): bump golang.org/x/sync from 0.23.0 to 0.24.0 (#2827)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/sync/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 11:56:34 +08:00
dependabot[bot] 892f2f9604 build(deps): bump gitpython from 3.1.59 to 3.1.62 in /docs (#2820)
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.59 to 3.1.62.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.59...3.1.62)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.62
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 09:12:30 +08:00
dependabot[bot] eeec9e17a2 build(deps): bump go.opentelemetry.io/otel/sdk/metric from 1.46.0 to 1.47.0 (#2815)
build(deps): bump go.opentelemetry.io/otel/sdk/metric

Bumps [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) from 1.46.0 to 1.47.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk/metric
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 09:11:46 +08:00
dependabot[bot] f1c86aa9dd build(deps): bump github.com/sashabaranov/go-openai from 1.42.1 to 1.43.0 (#2819)
build(deps): bump github.com/sashabaranov/go-openai

Bumps [github.com/sashabaranov/go-openai](https://github.com/sashabaranov/go-openai) from 1.42.1 to 1.43.0.
- [Release notes](https://github.com/sashabaranov/go-openai/releases)
- [Commits](https://github.com/sashabaranov/go-openai/compare/v1.42.1...v1.43.0)

---
updated-dependencies:
- dependency-name: github.com/sashabaranov/go-openai
  dependency-version: 1.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 08:41:01 +08:00
dependabot[bot] 25cd13e148 build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.113.4 to 1.114.0 (#2818)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.113.4 to 1.114.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.113.4...service/s3/v1.114.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.114.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 08:40:57 +08:00
Dominik Schmidtandyxxhero 443cef0dc1 feat: dir= selector for path-based release filtering and traversal skip (#2609)
* feat: add dir= selector for path-based release filtering and traversal skip

Introduce a new selector key `dir=<path>` to the -l/--selector flag with two
effects: it filters releases by the directory of their defining helmfile
relative to the root helmfile (using directory-prefix matching, so
dir=apps/foo matches both apps/foo and apps/foo/sub), and it short-circuits
sub-helmfile traversal when paired with positive dir= constraints, so
non-matching branches in the helmfiles: tree are not parsed, templated, or
fetched.

The motivation is consuming aggregator-style upstreams that the operator
does not control (opendesk being the immediate example), where the user
cannot restructure the helmfile layout but still wants to act on a subset.

The dir label is auto-populated at filter time only; user-facing label
output is unchanged. The label key "dir" is reserved at state load.
Selectors that escape the root via .. or absolute paths, and the bare ".",
are rejected at parse time.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* refactor: use sync.Once for root dir resolution, extract skipForDirFilter

Add a unit test covering the per-entry skip decision: match descends,
sibling skipped, remote never short-circuited.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* cmd: shorten dir selector help text, link to docs

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* docs: highlight selectors inheritance caveat, add bases example for dir label

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* feat: warn instead of fail on user-defined dir label

Hard-failing on an existing dir label is a breaking change. Log a
deprecation warning for now and turn it into an error in a later release.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* refactor: apply go fix modernizers to dir selector code

Upstream CI now fails when go fix would rewrite code. Use maps.Copy in
injectLabel and drop the explicit embedded ReleaseSetSpec in test
literals.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* fix: tighten dir selector edge cases found in review

- Always shadow a user-defined dir label at match time, also when the
  release has no auto-populated value (remote or outside-root helmfiles).
  Previously the user value was matched there despite the warning.
- Skip sibling helmfiles entries that are directories, not only files.
- Warn about a dir label declared in a release template, and warn only
  once when it comes from a bases file.
- Share the root-relative path and at-or-below checks between the release
  filter and the traversal skip; a directory whose name merely starts with
  two dots is no longer treated as escaping the root.
- Log when the root directory cannot be resolved.
- Tests: require for setup steps, t.Chdir, shared app fixture.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* docs: sync dir selector CLI help, document anchor and traversal assumption

docs/cli.md still carried the long help text. Also spell out what the dir
value is relative to, that the traversal skip assumes nested helmfiles
stay below their parent's directory, and that user-defined dir labels are
ignored for releases without an auto-populated value as well.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* fix: complete dir selector values relative to the root helmfile

Completion listed directories relative to the working directory, while
dir values are relative to the root helmfile directory. Resolve the root
from -f or HELMFILE_FILE_PATH with the same logic the app uses, and offer
nothing for values the selector would reject (absolute paths, paths that
escape the root) or for a remote root helmfile.

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>

* refactor(state): flatten nested conditionals in label filter parsing and matching

- LabelFilter.Match: extract labelMatches helper; positive/negative loops
  become single-level guards. Also removes the pre-existing empty
  then-branch (if !ok {} else if) in the negative-label loop and the
  redundant len()>0 guards around the loops
- positiveLabelsCompatibleWith: extract positiveLabelPairCompatible so the
  double loop holds one guard instead of a nested if chain
- ParseLabels: extract parseOneLabel (separator detection, dir= value
  validation, split) so the three-level regex/dir/error nesting becomes
  flat; drop the vestigial trailing 'var err error' return

No behavior change; state/app/cmd tests incl. -race and golangci-lint pass.

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

---------

Signed-off-by: Dominik Schmidt <dev@dominik-schmidt.de>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-10-06 08:21:35 +08:00
pznamenskyandyxxhero c25d16a438 add trackLogsUntilReady flag (#2814)
* add trackLogsUntilReady flag

Signed-off-by: pznamensky <kompastver@gmail.com>

* refactor(kubedog): flatten nested conditionals in trackLogsUntilReady filter

- podLogFilter.update: replace nested if-found/if-uid block with a single
  combined guard plus a no-op delete
- podLogFilter.watch: extract cutoffTrackedKind predicate and
  watchNamespacePods helper; use RWTransactionErr (kubedog's own idiom)
  instead of capturing err in plain RWTransaction closures, removing the
  'if err == nil { inf.Run() }' nesting
- TrackResources: extract newStartupLogFilter guard-clause helper so the
  printer wiring stays flat
- add table tests for the new helpers

No behavior change; race tests and golangci-lint pass.

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

---------

Signed-off-by: pznamensky <kompastver@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-10-04 08:49:05 +08:00
000a6fa660 fix: support escaped commas in --state-values-set (#2813)
* feat: support comma escape for values overwrites

Signed-off-by: zhengbayi <zhengbaiyi@sensetime.com>

* fix: harden state-values-set parsing and support \, in state-values-set-string

- Return a descriptive error instead of panicking when a --state-values-set
  assignment is missing '=', has an empty key, or ends with a trailing comma
- Support escaped commas in --state-values-set-string and preserve other
  backslashes (including trailing ones), aligning it with --state-values-set
  and Helm's --set/--set-string; quoted values keep working
- Allow empty values in --state-values-set-string for parity with
  --state-values-set
- Restructure parsing into guard-clause helpers with single-level
  conditionals; parse assignments via strings.Cut
- Extend table-driven tests: error cases, --state-values-set-string
  coverage, combined flags, and unit tests for the split/unescape helpers

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

---------

Signed-off-by: zhengbayi <zhengbaiyi@sensetime.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: zhengbayi <zhengbaiyi@sensetime.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-10-03 15:31:04 +08:00
Daniel Pinto 226c16b88e fix: drop leading dot from release condition error message (#2812)
- Build the reported key path from the keys walked so far, so a missing value reads
  `'fooo'` or `'rnd42.unknown'` instead of `'.fooo'`; the "is not a map" message uses it too.
- Which condition values count as enabled is unchanged.
- `TestConditionEnabled` now asserts the exact error text.

Signed-off-by: Cayan <1619617+Cayan@users.noreply.github.com>
2026-09-30 09:00:23 +08:00
pznamenskyandyxxhero 5ed585e223 add --track-logs-interval flag (#2807)
* add --track-logs-interval flag

Signed-off-by: pznamensky <kompastver@gmail.com>

* address review nits: logsInterval plumbing and test coverage

- kubedog: move defaultLogsInterval next to TrackOptions in options.go,
  where the field it defaults lives
- kubedog: pass logsInterval through newProgressPrinter instead of a
  post-construction field assignment; zero falls back to the default
- kubedog: cover WithLogsInterval and the LogsInterval default in
  tracker tests
- config: document that defaultTrackLogsInterval is shared by sync and
  apply and mirrors kubedog's default
- config: add a negative-interval validation test case

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

---------

Signed-off-by: pznamensky <kompastver@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-30 08:30:21 +08:00
dependabot[bot] 9263200ed7 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.33.5 to 1.33.6 (#2810)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.5 to 1.33.6.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.5...config/v1.33.6)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-30 07:40:23 +08:00
dependabot[bot] 79baa2b70e build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.113.2 to 1.113.4 (#2811)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.113.2 to 1.113.4.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.113.2...service/s3/v1.113.4)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.113.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-29 15:59:19 +08:00
jdxandClaude Opus 5.5 5d901f8ac1 ci: publish a signed packslip with each release (#2809)
* ci: publish a signed packslip with each release

Add a job after goreleaser that publishes a packslip.sigstore.json to
tag releases: a manifest of the release archives, the helmfile
executable in each, shell completions via `helmfile completion`, and
the skills/helmfile agent skill at the release commit, signed keylessly
with this workflow's OIDC identity and linked to build provenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jdx <216188+jdx@users.noreply.github.com>

* ci: include the 386 archives in the packslip

packslip 1.4.0 reads goreleaser's 386 as i686 and leaves libc out for
static Linux builds, so every archive can be listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: jdx <216188+jdx@users.noreply.github.com>

---------

Signed-off-by: jdx <216188+jdx@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:49:35 +08:00
dependabot[bot] c36d150a3a build(deps): bump github.com/helmfile/vals from 0.46.1 to 0.47.0 (#2806)
Bumps [github.com/helmfile/vals](https://github.com/helmfile/vals) from 0.46.1 to 0.47.0.
- [Release notes](https://github.com/helmfile/vals/releases)
- [Commits](https://github.com/helmfile/vals/compare/v0.46.1...v0.47.0)

---
updated-dependencies:
- dependency-name: github.com/helmfile/vals
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 21:03:23 +08:00
dependabot[bot] 5168ed9187 build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.113.1 to 1.113.2 (#2808)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.113.1 to 1.113.2.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.113.1...service/s3/v1.113.2)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.113.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-28 18:26:54 +08:00
Johannes Schmidtandyxxhero a219b5dbd2 fix: remove partially extracted chart when helm pull fails (#2805)
extracts in place, so a failed fetch can leave a partial chart in the cache. `forcedDownloadChart` returns the error but never removes the partial directory, and `findChartDirectory` accepts any directory containing a Chart.yaml, so the next run adopts the leftover as a valid cache entry and renders zero manifests without error. Remove the partial download while still holding the exclusive chart lock so the failure stays a failure.

Co-authored-by: yxxhero <aiopsclub@163.com>
Signed-off-by: Johannes Schmidt <jschmidt@canarytechnologies.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
2026-09-23 13:14:13 +08:00
Tsukoyachiandyxxhero ca58090af4 feat: add per-release continue-on-error support (#2804)
* feat: add per-release continue-on-error support

Work in progress for discussion #2799.

This introduces the initial release-level configuration needed to
continue processing independent releases after a deployment failure.
The DAG execution and failure propagation behavior are still being
implemented.

Refs #2799

Signed-off-by: Axel Delille <Axel.delille31@gmail.com>

* fix: address review findings for continueOnError

Review fixes for the per-release continueOnError feature:

1. Regenerate values-file ID hashes in pkg/state/temp_test.go — adding
   ContinueOnError to ReleaseSpec shifts generateValuesID hashes, which
   broke TestGenerateID (the test file notes these must be regenerated
   whenever ReleaseSpec changes).

2. Remove the dead skippedErrors variable in withBatches and instead log
   each skipped release with logger.Warnf at decision time, so users see
   why a release never ran instead of only learning from the final error
   list.

3. Aggregate all errors from a state file instead of returning only
   errs[0] in visitStatesWithContext/processStateFileParallel. With
   continueOnError, multiple releases can fail or be skipped in one run;
   reporting only the first error hid the skip errors (and other
   failures), contradicting the feature's contract. Single-error
   rendering is unchanged.

4. Gate tolerated errors on ReleaseErrorCodeFailure so that non-failure
   release errors (e.g. helm-diff's "changes detected" exit code 2 can
   never enable continuation or block dependents.

5. Report skipped-release messages with the dependency's plain release
   name instead of its kubeContext/namespace-qualified needs id
   (e.g. dependency database instead of default/default/database),
   matching the documented message format.

6. Restructure the withBatches helpers into guard-clause style
   (filterBlockedReleases, toleratesBatchErrors) and drop the test-only
   logger nil-guards in favor of a nop logger in tests.

7. Add end-to-end coverage through App.Sync with the exectest fake helm:
   independent releases continue after a tolerated failure, dependents
   are skipped with an explicit error, the exit code stays non-zero, and
   fail-fast remains the default without continueOnError. Also cover the
   non-failure error code case at the withBatches level.

8. Use new(true) instead of a boolPtr helper (CI-enforced check-modernize)
   and document the failure-handling interaction in docs/releases.md.

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

---------

Signed-off-by: Axel Delille <Axel.delille31@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-21 07:48:50 +08:00
dependabot[bot]andyxxhero 58417fd068 build(deps): bump github.com/hashicorp/hcl/v2 from 2.24.0 to 2.25.0 (#2802)
Bumps [github.com/hashicorp/hcl/v2](https://github.com/hashicorp/hcl) from 2.24.0 to 2.25.0.
- [Release notes](https://github.com/hashicorp/hcl/releases)
- [Changelog](https://github.com/hashicorp/hcl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hashicorp/hcl/compare/v2.24.0...v2.25.0)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/hcl/v2
  dependency-version: 2.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: yxxhero <yxxhero@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-19 12:19:18 +08:00
dependabot[bot] 44c9e8d958 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.33.4 to 1.33.5 (#2801)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.4 to 1.33.5.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.4...config/v1.33.5)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 11:27:02 +08:00
dependabot[bot] a2cc8e97d4 build(deps): bump github.com/helmfile/chartify from 0.28.2 to 0.29.0 (#2803)
Bumps [github.com/helmfile/chartify](https://github.com/helmfile/chartify) from 0.28.2 to 0.29.0.
- [Release notes](https://github.com/helmfile/chartify/releases)
- [Commits](https://github.com/helmfile/chartify/compare/v0.28.2...v0.29.0)

---
updated-dependencies:
- dependency-name: github.com/helmfile/chartify
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 11:23:34 +08:00
yxxheroandClaude ad81e4231e build: update Go to 1.27.1 and modernize the codebase (#2798)
Toolchain and CI:
- Bump go directive from 1.26.8 to 1.27.1 (go.mod)
- Use golang:1.27-alpine builder images in all Dockerfiles
- Bump golangci-lint to v2.13.2 (first release with go1.27 support)
- Add CI gate: `go fix -diff` fails when outdated Go patterns are
  detected (locally: `make check-modernize`)

Note: darwin binaries now require macOS 13 or later.

Lint fixes required by golangci-lint v2.13.2:
- goconst: ignore tests (all 436 findings were test-only; goconst
  got stricter since v2.12 and this option was added for it)
- openai.go: keep deprecated MaxTokens deliberately with a nolint
  rationale (max_tokens is the only form universally supported by
  OpenAI-compatible backends like One-API, LiteLLM, Ollama shim)
- state.go: drop always-nil flags param from appendChartVersionFlags
  (renamed to chartVersionFlags, unparam)

Modernization (go fix ./..., 62 files):
- interface{} -> any, maps.Copy, strings.SplitSeq, range-over-int,
  builtin min/max, slices.Contains/ContainsFunc/Sort, WaitGroup.Go,
  reflect.Type.Fields(), new(expr)
- exit_error.go: strings.Builder + fmt.Fprintf instead of string
  concatenation and WriteString(fmt.Sprintf(...)) (QF1012)
- chart_dependency.go: strings.CutLast for OCI dependency helpers

Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-16 13:29:02 +08:00
dependabot[bot] 88be4012b1 build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.111.0 to 1.113.1 (#2797)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.111.0 to 1.113.1.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.111.0...service/s3/v1.113.1)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.113.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 10:59:10 +08:00
dependabot[bot] c390dadce6 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.33.3 to 1.33.4 (#2794)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.3 to 1.33.4.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.3...config/v1.33.4)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 07:46:43 +08:00
dependabot[bot] 0d14c5c0de build(deps): bump github.com/hashicorp/go-getter/v2 from 2.2.3 to 2.2.4 (#2793)
Bumps [github.com/hashicorp/go-getter/v2](https://github.com/hashicorp/go-getter) from 2.2.3 to 2.2.4.
- [Release notes](https://github.com/hashicorp/go-getter/releases)
- [Changelog](https://github.com/hashicorp/go-getter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hashicorp/go-getter/compare/v2.2.3...v2.2.4)

---
updated-dependencies:
- dependency-name: github.com/hashicorp/go-getter/v2
  dependency-version: 2.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 07:39:16 +08:00
vulragrag-starandyxxhero d4a4135f9b fix: cancel kubedog-tracked helm subprocesses on SIGINT/SIGTERM (#2791)
* fix: cancel kubedog-tracked helm subprocesses on SIGINT/SIGTERM

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

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

Fixes #2770

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

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

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

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

---------

Signed-off-by: Jason Wang <vulragrag@gmail.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-15 07:35:09 +08:00
dependabot[bot] fff84390b1 build(deps): bump github.com/sashabaranov/go-openai from 1.42.0 to 1.42.1 (#2792)
build(deps): bump github.com/sashabaranov/go-openai

Bumps [github.com/sashabaranov/go-openai](https://github.com/sashabaranov/go-openai) from 1.42.0 to 1.42.1.
- [Release notes](https://github.com/sashabaranov/go-openai/releases)
- [Commits](https://github.com/sashabaranov/go-openai/compare/v1.42.0...v1.42.1)

---
updated-dependencies:
- dependency-name: github.com/sashabaranov/go-openai
  dependency-version: 1.42.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 07:28:49 +08:00
dependabot[bot] aa18bd0613 build(deps): bump golang.org/x/term from 0.45.0 to 0.46.0 (#2795)
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.45.0 to 0.46.0.
- [Commits](https://github.com/golang/term/compare/v0.45.0...v0.46.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 07:09:27 +08:00
dependabot[bot] f7016d1d39 build(deps): bump github.com/mattn/go-runewidth from 0.0.29 to 0.0.30 (#2796)
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.29 to 0.0.30.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.29...v0.0.30)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 07:08:47 +08:00
jimmyRandyxxhero bc0c103d79 feat: add HELMFILE_DISABLE_VALS env var to skip vals processing (#2471)
Signed-off-by: Jim Robinson <1643772+jimmyR@users.noreply.github.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-13 15:40:12 +08:00
yxxhero 0d6f3ab160 build(deps): bump helmfile/vals from v0.46.0 to v0.46.1 (#2789)
Bump github.com/helmfile/vals from v0.46.0 to v0.46.1.

vals v0.46.1 is a dependency-maintenance release with no API changes;
it also bumps its go directive to 1.26.6, so this updates the go
directive in go.mod accordingly (CI reads the Go version from go.mod).

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-09-13 09:19:02 +08:00
hoppla20andClaude Opus 5 daa43e1081 feat(remote): support wildcards in remote values/secrets file selectors (#2787)
Extend git-getter style remote references (git::, s3::, https://, ...) used
in release and environment values/secrets to support glob patterns in the
file selector, e.g.:

  git::https://github.com/org/repo.git@config/*.yaml?ref=main

Remote.Fetch already downloads the whole repository/directory and joins the
"@<file>" selector onto it verbatim, so a wildcard selector already survives
untouched; the only missing piece was that Storage.resolveFile checked the
result with FileExistsAt instead of expanding it as a glob.

- pkg/remote/remote.go: add HasGlobPattern to detect a wildcard in the file
  selector (checking only the selector, not the raw URL, so "?ref=main" and
  IPv6/placeholder brackets elsewhere are not mistaken for wildcards). Reject
  wildcards in Fetch for getter shapes that can never expand one: plain
  http(s)/s3 (single object), non-archive forced s3:: (single object), and
  any getter used without an explicit "@" selector (Dir/File cannot be
  reliably split from the pattern otherwise).
- pkg/state/storage.go: resolveFile now globs the fetched cache path with the
  same st.fs.Glob/sort.Strings used for local values-file globs when the
  selector is a pattern, filtering out directory matches. A literal, existing
  path is still resolved directly. Fixed an existing err-shadowing hazard in
  the same code path while restructuring it.
- docs/environments.md: document the new wildcard support, its syntax
  (filepath.Match, no recursive **), and its getter/selector requirements.
- Tests: new cases in pkg/remote/remote_test.go (glob detection, Fetch
  wildcard expansion and cache-key sharing, rejected getter shapes) and
  pkg/state/storage_test.go (a real end-to-end wildcard fetch against a
  pinned upstream tag, plus a hermetic fan-out/sorting/missing-file test with
  no network access).

Release values/secrets keep their existing "glob patterns ... not supported
yet" restriction for multi-file matches (pkg/state/state.go), unchanged by
this commit and applying equally to local and remote globs. helmfiles: entries
are out of scope.

Signed-off-by: Vincent Cui <privat@vincentcui.de>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 09:16:26 +08:00
yxxhero 6e7aafd5e5 build(deps): bump helm-diff to v3.15.13 (#2788)
* build(deps): bump helm-diff to v3.15.13

Update helm-diff plugin version from v3.15.12 to v3.15.13 across
Dockerfiles, recommended version constant, CI matrix, and
integration test default.

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

* fix(ci): add golden files for helm-diff >= 3.15.13

helm-diff v3.15.13 omits null/empty labels and annotations keys from
diffs (databus23/helm-diff#1068), which broke the suppress-output-line-
regex integration test: the ValidatingWebhookConfiguration manifest no
longer renders a bare 'annotations:' key under metadata.

Add new golden files suffixed -after-helm-diff-3.15.13 (helm3, helm4,
live variants) and select them via version_ge in the test case, keeping
the existing 3.11.0 and pre-3.11.0 goldens for older plugin versions.

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

* refactor(test): drop legacy helm-diff golden files

CI only exercises the recommended helm-diff version, so replace the
version-suffixed golden files (-after-helm-diff-3.11.0,
-after-helm-diff-3.15.13) with a single set of base golden files
(diff, diff-live, diff-helm4, diff-live-helm4) containing the
v3.15.13 output, and remove the now-dead version gating.

Also drop the 'install semver' CI step, whose only consumer was the
removed gating.

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

* fix(test): remove helm-diff version gating from golden file selection

The previous commit deleted the version-suffixed golden files but the
selection gating was accidentally left behind, making the test reference
non-existent files for helm-diff >= 3.15.13.

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

---------

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-09-11 17:01:59 +08:00
yxxhero 9d1a953ec2 chore: bump Helm to v3.22.0 and v4.3.0 (#2786)
- Update CI test matrix from v3.21.4/v4.2.4 to v3.22.0/v4.3.0
- Update Helm v4.3.0 SHA256 checksums in Dockerfiles
- Update go.mod dependencies (helm.sh/helm/v3 v3.22.0, helm.sh/helm/v4 v4.3.0)
- Update HelmRecommendedVersion to v4.3.0

Signed-off-by: yxxhero <aiopsclub@163.com>
2026-09-11 12:14:32 +08:00
FloandClaude Fable 5.1 5a241f68dc fix(list): avoid deadlock in helmfile list with more than 100 states (#2773)
fix(list): collect releases under a mutex to avoid deadlock with more than 100 states

ListReleases gathered per-state results through a channel with a fixed
buffer of 100 that was drained only after ForEachState had visited every
state. The 101st state carrying releases therefore blocked on the send
forever: `helmfile list` hung with no output and ignored SIGTERM.

Append to the result slice under a mutex instead. ForEachState may run the
callback concurrently, and the existing sort keeps the output order
deterministic, so nothing else changes.

Adds TestListWithManyStates (101 helmfile.d states, fails fast on a hang).

Resolves #2772

Signed-off-by: Florian Müller <7556827+max06@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 07:53:07 +08:00
dependabot[bot] 0605da273a build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.110.0 to 1.111.0 (#2779)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.110.0 to 1.111.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.110.0...service/s3/v1.111.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.111.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:55:29 +08:00
dependabot[bot] 2bf38beab1 build(deps): bump go.opentelemetry.io/contrib/exporters/autoexport from 0.67.0 to 0.71.0 (#2780)
build(deps): bump go.opentelemetry.io/contrib/exporters/autoexport

Bumps [go.opentelemetry.io/contrib/exporters/autoexport](https://github.com/open-telemetry/opentelemetry-go-contrib) from 0.67.0 to 0.71.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.67.0...zpages/v0.71.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/contrib/exporters/autoexport
  dependency-version: 0.71.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:40:56 +08:00
dependabot[bot] 99351f1010 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.33.2 to 1.33.3 (#2781)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.2 to 1.33.3.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.2...config/v1.33.3)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:40:33 +08:00
dependabot[bot] d6353a89df build(deps): bump golang.org/x/sync from 0.22.0 to 0.23.0 (#2782)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/sync/compare/v0.22.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:40:10 +08:00
dependabot[bot] d29478328b build(deps): bump google.golang.org/protobuf from 1.36.12-0.20260120151049-f2248ac996af to 1.36.12 (#2783)
build(deps): bump google.golang.org/protobuf

Bumps google.golang.org/protobuf from 1.36.12-0.20260120151049-f2248ac996af to 1.36.12.

---
updated-dependencies:
- dependency-name: google.golang.org/protobuf
  dependency-version: 1.36.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:27:22 +08:00
dependabot[bot] aa5d748600 build(deps): bump google.golang.org/grpc from 1.83.1 to 1.83.2 (#2784)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.1 to 1.83.2.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.1...v1.83.2)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:16:46 +08:00
dependabot[bot] 822fa3afef build(deps): bump gitpython from 3.1.58 to 3.1.59 in /docs (#2785)
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.58 to 3.1.59.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-version: 3.1.59
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-10 08:12:46 +08:00
dependabot[bot]andyxxhero 7e4c0920da build(deps): bump go.opentelemetry.io/otel/sdk from 1.44.0 to 1.46.0 (#2775)
Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.46.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.46.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
2026-09-08 07:52:27 +08:00
dependabot[bot] de7b58cd13 build(deps): bump go.opentelemetry.io/proto/otlp from 1.10.0 to 1.11.0 (#2774)
Bumps [go.opentelemetry.io/proto/otlp](https://github.com/open-telemetry/opentelemetry-proto-go) from 1.10.0 to 1.11.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-proto-go/releases)
- [Commits](https://github.com/open-telemetry/opentelemetry-proto-go/compare/v1.10.0...v1.11.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/proto/otlp
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 07:51:49 +08:00
dependabot[bot] 0050dfd79b build(deps): bump go.opentelemetry.io/otel/sdk/metric from 1.44.0 to 1.46.0 (#2777)
build(deps): bump go.opentelemetry.io/otel/sdk/metric

Bumps [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.46.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.46.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk/metric
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 07:51:16 +08:00
dependabot[bot] 677d374502 build(deps): bump github.com/mattn/go-runewidth from 0.0.28 to 0.0.29 (#2776)
Bumps [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) from 0.0.28 to 0.0.29.
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.28...v0.0.29)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 07:11:29 +08:00
dependabot[bot] 165e7a394f build(deps): bump go.opentelemetry.io/otel from 1.44.0 to 1.46.0 (#2778)
Bumps [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) from 1.44.0 to 1.46.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.46.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.46.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 07:10:59 +08:00
yxxhero 16259008d5 feat: opt-in OpenTelemetry tracing and metrics (experimental) (#2769)
* 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>
2026-09-07 20:34:10 +08:00
c36dbfd417 fix: resolve OCI version constraints before deriving the shared chart cache path (#2768)
* fix: resolve OCI version constraints before deriving the shared chart cache path

When an OCI release uses a semver constraint (e.g. `~1`, `^2.0.0`, `*`),
`getOCIChartPath` currently derives the on-disk cache directory from the
raw constraint string via `safeVersionPath`, which substitutes constraint
characters (`~`, `^`, `>`, `<`, `!`, `|`, `=`, ` `, `,`, `*`) with `_`.
So `version: ~1` becomes `.../mychart/_1/` on disk. `acquireChartLock`
then refuses to refresh anything under the shared cache dir to avoid
race conditions between concurrent processes, so once the constraint is
first resolved and written to `_1/`, every subsequent render on that
machine (or that container replica) returns the pinned tarball
regardless of newer matching tags being published.

In multi-pod deployments like ArgoCD's argocd-repo-server this shows up
as intermittent stale renders: different pods populate their caches at
different moments and serve different snapshots of the same `~1`
release forever.

Fix: for OCI releases whose `version` looks like a constraint, run
`helm show chart <ref> --version <constraint> [flags]` and use the
returned metadata.Version as the effective version for all downstream
cache-key and path derivation. Helm already resolves the constraint
against the registry and returns the concrete matching Chart.yaml.
Callers get a content-addressable cache path (`.../mychart/1.0.1/`)
that naturally invalidates when the constraint resolves to a new
version. Exact-version releases and non-OCI releases skip the extra
call.

Adds an opt-out `resolveOCIVersions` field on `helmDefaults` and
`ReleaseSpec` (both default true). If the resolution call fails
transiently, the resolver logs a warning and falls back to the
pre-fix behavior so a network hiccup doesn't break rendering.

Adds `ShowChartWithFlags` to helmexec.Interface so the existing
`ShowChart` API stays backwards compatible.

Resolves #2766

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* refactor: move ShowChartWithFlags to a ChartInspector capability interface

Address Copilot review feedback on PR #2768: adding a method to the
exported helmexec.Interface is a source-breaking change for every
third-party implementation and mock of that interface, even though
ShowChart itself stayed backward-compatible.

Move ShowChartWithFlags off Interface and onto a new capability
interface, helmexec.ChartInspector, following the same pattern used
by the existing DependencyUpdater capability interface. The concrete
execer and the exectest.Helm test stub still satisfy it (they
already have the method); the OCI resolver in state.HelmState now
type-asserts and falls back to the pre-fix caching behavior when the
capability is absent, so downstream callers with their own
helmexec.Interface implementations keep compiling untouched.

Adds TestResolveOCIConstraintVersion_ChartInspectorFallback that
exercises the type-assertion path with a helm value that satisfies
Interface but deliberately does not satisfy ChartInspector.

Reverts ShowChartWithFlags additions from testutil.noCallHelmExec
and app_test.mockHelmExec since Interface no longer requires them.

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* fix: detect wildcard-segment semver constraints (1.x, 1.X) as constraints

Address Copilot review feedback on PR #2768: the previous
isVersionConstraint implementation scanned the input for operator
characters (~, ^, >, <, !, |, =, space, comma, *). Masterminds/semver
also accepts wildcard-segment constraints like "1.x", "1.X", "1.x.x",
and "1.2.X" that contain no operator characters. Those would slip past
the classifier, bypass OCI constraint resolution, and remain cached
forever under the raw ".../mychart/1.x/" path — the same stale-cache
bug the PR is meant to fix.

Replace the character scan with a semver-parser-based check: a value
is a constraint iff Masterminds/semver rejects it as a NewVersion but
accepts it as a NewConstraint. This correctly:

  - Recognizes wildcard forms (1.x, 1.X, 1.x.x, 1.2.x, v1.x).
  - Preserves exact versions where "x" appears in prerelease metadata
    ("1.0.0-alpha.x") or build metadata ("1.0.0+x", "1.0.0+build.x.1")
    without misclassifying them, which a naive "add x to the scanned
    charset" fix would have gotten wrong.
  - Continues to classify values that are neither a version nor a
    constraint (empty string, "latest", junk) as non-constraints; helm
    handles those elsewhere.

Removes the now-unused versionConstraintChars string constant.

Expands TestIsVersionConstraint with 8 wildcard cases and 3 prerelease
/build metadata cases containing "x", plus 2 non-parseable inputs.
Adds a "wildcard segment constraint resolves to concrete version"
subtest to TestResolveOCIConstraintVersion so the end-to-end pipeline
is exercised for a version string that has no operator characters.

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* test: add getOCIChart integration test proving cache-path/pull-flag wiring

Address Copilot review feedback on PR #2768. The existing unit test
exercised resolveOCIConstraintVersion in isolation but did not prove
that its output was propagated into the downstream cache key, cache
path, and `helm chart pull --version` flag. Add a targeted
integration test that:

  1. Calls getOCIChart with a constraint release (`~1`) and a helm
     mock whose ShowChartWithFlags returns Chart.yaml version 1.0.1.
  2. Asserts helm chart pull receives `--version 1.0.1`, not `~1`.
  3. Asserts the destination path passed to helm chart pull contains
     the resolved-version segment (`/1.0.1/`) and does NOT contain
     the raw-constraint segment (`/_1/`).
  4. Reads back the on-disk Chart.yaml under the cache path to
     confirm resolved version, path, and flag agree end to end.

Add a second test that runs the same release twice with different
resolver outputs (1.0.1, then 1.0.2 — simulating a newly published
matching tag) and asserts the two resolutions land in distinct cache
directories. This is the promise of the fix: once the raw constraint
is out of the path, a new matching tag stops silently reusing the
previously-resolved cache entry.

The integration test flushed out a real correctness gap in the
initial fix: getOCIChart resolved release.Version and chartVersion
but did NOT recompute the qualified OCI ref that
getOCIQualifiedChartName built pre-resolution. Helm was therefore
receiving `oci://<repo>/<chart>:<constraint>` alongside a
`--version <resolved>` flag — at best redundant, at worst rejected
by future Helm versions. Fixed by re-invoking
getOCIQualifiedChartName on the mutated release copy so the
embedded tag also carries the resolved value.

Isolates the shared helmfile cache via `t.Setenv(HELMFILE_CACHE_HOME,
t.TempDir())` so the OutputDirTemplate == "" code path (which writes
into remote.CacheDir) does not touch the user's real
`~/.cache/helmfile` during test runs.

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>

* refactor: flatten OCI constraint-resolution wiring in getOCIChart

Address review feedback on PR #2768:

- Extract the inline resolve/requalify block from getOCIChart into
  applyOCIConstraintResolution, keeping getOCIChart flat (guard-clause
  style) and making the resolution wiring independently testable. The
  helper returns the (possibly updated) release, qualified chart name,
  and chart version; every failure mode returns its inputs unchanged.

- On a re-qualify failure after a successful resolution, fall back to
  the pre-fix behavior entirely (raw constraint in cache key, ref, AND
  --version flag) instead of the previous half-resolved mix (resolved
  version in the cache key, raw constraint in the path and flag), which
  could desynchronize the in-process cache key from the on-disk path.

- Build the 'helm show chart' ref by reusing parseOCIChartRef instead of
  re-implementing its last-slash/last-colon tag-splitting inline. Same
  behavior for all realistic refs (registry ports preserved), and it
  also handles the digest suffix should one ever reach this point.

- Drop --devel from the resolver flags: helm documents --devel as
  ignored whenever --version is set, and --version is always passed on
  this path.

No behavior change intended beyond the requalify-failure fallback
(which cannot realistically trigger) and the removal of the inert
--devel flag.

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

* fix: classify partial semver versions (1, 1.2) as OCI constraints

Address review feedback on PR #2768: Masterminds' lenient parser accepts
partial versions like "1" or "1.2" as versions, so the previous
classifier (NewVersion fails && NewConstraint succeeds) treated them as
exact pins. But helm's OCI resolution — registry.GetTagMatchingVersionOrConstraint
— honors a version string as an exact pin ONLY when a registry tag
literally equals it; otherwise it parses the string as a constraint, and
"1"/"1.2" float across 1.x.y/1.2.y tags. Caching those under their raw
spelling reproduces the stale-cache bug of issue #2766, just with a
narrower trigger.

Replace the NewVersion probe with isFullSemver, which additionally
requires the whole major.minor.patch triple to be spelled out
(optional v prefix, prerelease, and build metadata all still count as
exact when the core is fully qualified). When a registry does carry a
literal tag equal to the version string, the resolver's
metadata.Version == chartVersion path reports no change, so literal-tag
pins keep today's behavior.

TestIsVersionConstraint: "1"/"1.0" flip to constraints, joined by new
v1.2/0/v1 cases and a 1.2.3 exact case. TestResolveOCIConstraintVersion
gains a "partial version resolves" subtest.

Docs updated to describe the parser-based classification instead of
"constraint characters".

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

* fix: skip OCI constraint resolution under skipRefresh

Address review feedback on PR #2768: the resolver ran even under
--skip-refresh, so offline and cache-only workflows gained a
'helm show chart' registry attempt per constraint-versioned OCI release.
It degraded gracefully (warn + fallback), but added registry-timeout
latency and warning noise per release.

skipOCIConstraintResolution now suppresses resolution when any of the
skipRefresh levels is set — CLI --skip-refresh (forced), per-release
skipRefresh, or helmDefaults.skipRefresh — with the same precedence the
other skipRefresh consumers in prepareChartForRelease use. Skipped runs
fall back to the constraint-keyed cache path, i.e. they reuse whatever a
previous non-skipped run resolved, which is what 'skip checking for
updates to cached charts' means for constraint versions.

The existing issue #2766 integration tests flip their opts to
SkipRefresh: false since they assert resolution happens. New coverage:
TestSkipOCIConstraintResolution (tri-state precedence table) and
TestGetOCIChart_SkipRefreshSkipsConstraintResolution (no inspector call,
raw constraint in --version and cache path).

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

* perf: memoize OCI constraint resolution per chart+constraint

Address review feedback on PR #2768: resolution ran before the
in-process chart-cache fast path and was not memoized, so every
constraint-versioned OCI release paid its own 'helm show chart' registry
round-trip on every render — including N releases sharing the same
chart+constraint, whose parallel workers could even resolve to different
versions if the registry changed between their lookups.

Memoize successful resolutions in resolvedOCIConstraints keyed by
(chart ref, constraint), mirroring the downloadedCharts pattern:

- Releases sharing a chart+constraint cost one round-trip per process
  and consistently use one resolved version per run.
- Only successful resolutions are memoized; failures may be transient.
- Flags are not part of the key: they govern TLS/verification/registry
  credentials, not which tag a constraint matches (--devel is already
  omitted as it is ignored whenever --version is set).
- Concurrent misses may both hit the registry; last write wins,
  harmlessly.

resetResolvedOCIConstraintsForTest is added alongside the existing
resetChartCacheForTest and wired into the issue #2766 tests — notably
ResolvesToDifferentVersionsPicksSeparateCachePaths, which reuses the
same chart+constraint across its two runs and would otherwise be served
the first resolution from the memo (which is exactly the intended
per-process semantics).

New coverage: TestResolveOCIConstraintVersion_Memoized (memo hit skips
the registry, different constraint is a different key) and
TestGetOCIChart_SharedConstraintResolvedOncePerProcess (two releases,
one inspector call, one pull, same path).

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

* test: cover URL-embedded OCI constraint resolution

Address review feedback on PR #2768: the existing integration tests only
exercised the repo-aliased spelling (chart: myrepo/mychart, version:
'~1') and the version-field spelling. The chart-URL spelling
(chart: oci://<registry>/<chart>:~1) takes a different branch in
getOCIQualifiedChartName — the URL version is deliberately NOT embedded
into the qualified ref and flows through --version only — so its
re-qualification after constraint resolution (release.Version mutated to
the resolved value, versionInURL still the constraint) was untested.

TestGetOCIChart_URLEmbeddedConstraintResolves asserts the resolver
receives the URL-embedded constraint, helm chart pull receives the
resolved version via --version with a tag-less ref, and the cache path
carries the resolved version segment instead of the raw constraint.

Also gofmt-aligns the test tables added in earlier commits and drops a
redundant 1.2.3 test case that tripped goconst.

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

* docs: note empty-version OCI releases are unaffected by resolveOCIVersions

Releases with no version: at all keep their pre-existing semantics: helm
picks the latest tag at pull time and helmfile caches it under a
version-less shared-cache path. Document the limitation alongside the
other resolveOCIVersions scope notes.

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

---------

Signed-off-by: Samuel Archambault <samuel.archambault@getmaintainx.com>
Signed-off-by: yxxhero <aiopsclub@163.com>
Co-authored-by: Samuel Archambault <samuel.archambault@getmaintainx.com>
Co-authored-by: yxxhero <aiopsclub@163.com>
2026-09-07 16:50:25 +08:00
dependabot[bot] 9d6cc4d84f build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.33.1 to 1.33.2 (#2765)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.33.1 to 1.33.2.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.1...config/v1.33.2)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 08:00:45 +08:00
dependabot[bot] 3fd00b3683 build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.109.1 to 1.110.0 (#2764)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.109.1 to 1.110.0.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.109.1...service/s3/v1.110.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.110.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 07:54:23 +08:00
34ead21107 feat: allow helmfile to continue on failed releases (#2616)
* feat: allow helmfile to continue on failed releases

Co-authored-by: Peter Honeder <peter.honeder@unwired.at>
Signed-off-by: Niklas Ott <niklas.ott@unwired.at>

* fix: skip failed-prep releases, complete flag wiring, add tests and docs (#64)

Review follow-ups for --allow-failed-releases (#2616):

- Track per-release chart preparation failures in PrepareCharts (returned
  as a map keyed by release) and remove those releases from the state in
  Run.WithPreparedCharts when --allow-failed-releases is set, so a failed
  release is never executed against its original, un-prepared chart
  reference (which could either fail again with a duplicate error or, for
  charts requiring chartify, bypass patches/dependency modifications and
  produce an unintended result). All failures are still reported at the
  end via the aggregated MultiError.
- Complete the release identity on error results from
  prepareChartForRelease so failures are attributed to the correct
  release.
- With --allow-failed-releases, continue building dependencies of the
  remaining charts when 'helm dep build' fails for one release, and skip
  the affected releases during execution.
- Wire --allow-failed-releases into 'helmfile unittest' and 'helmfile
  status'; remove the dead flag wiring for write-values and list (both
  never prepare charts, see commandsSkipChartPrep).
- Simplify control flow (guard clauses, errors.As, drop dead code and
  redundant else branches).
- Add end-to-end coverage in pkg/app/issue_2616_test.go and extend the
  state-level tests; document the flag in docs/cli.md and CHANGELOG.md.

Signed-off-by: yxxhero <11087727+yxxhero@users.noreply.github.com>

---------

Signed-off-by: Niklas Ott <niklas.ott@unwired.at>
Signed-off-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
Co-authored-by: Peter Honeder <peter.honeder@unwired.at>
Co-authored-by: yxxhero <11087727+yxxhero@users.noreply.github.com>
2026-09-03 06:26:46 +08:00
dependabot[bot] 2e81265f2e build(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1 (#2763)
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.83.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-03 06:25:42 +08:00
dependabot[bot] d40bfcea6f build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3 from 1.107.3 to 1.109.1 (#2760)
build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3

Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.107.3 to 1.109.1.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.107.3...service/s3/v1.109.1)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.109.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 08:12:22 +08:00
dependabot[bot] 2aaae60524 build(deps): bump github.com/gofrs/flock from 0.13.0 to 0.13.1 (#2761)
Bumps [github.com/gofrs/flock](https://github.com/gofrs/flock) from 0.13.0 to 0.13.1.
- [Release notes](https://github.com/gofrs/flock/releases)
- [Commits](https://github.com/gofrs/flock/compare/v0.13.0...v0.13.1)

---
updated-dependencies:
- dependency-name: github.com/gofrs/flock
  dependency-version: 0.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 08:08:32 +08:00
dependabot[bot] 55efdd8a35 build(deps): bump github.com/aws/aws-sdk-go-v2/config from 1.32.38 to 1.33.1 (#2762)
build(deps): bump github.com/aws/aws-sdk-go-v2/config

Bumps [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) from 1.32.38 to 1.33.1.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.32.38...config/v1.33.1)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-01 08:03:28 +08:00
167 changed files with 11812 additions and 3990 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
HELM_VERSION ?= v4.2.4
HELM_VERSION ?= v4.3.1
KUSTOMIZE_VERSION ?= v5.8.0
K8S_VERSION ?= v1.34.0
MINIKUBE_VERSION ?= v1.37.0
+21 -17
View File
@@ -31,13 +31,19 @@ jobs:
run: df -h
- uses: golangci/golangci-lint-action@v9
with:
version: v2.11.4
version: v2.13.2
- name: Check for outdated Go patterns (go fix modernizers)
run: |
# Fails (exit 1) when any `go fix` modernizer produces a diff, i.e. the
# code contains outdated Go patterns that `go fix ./...` would rewrite.
# Fix locally with: go fix ./... && make fmt
go fix -diff ./...
tests:
runs-on: ubuntu-latest
strategy:
matrix:
helm-version: [v3.18.6, v3.21.4, v4.2.4]
helm-version: [v3.18.6, v3.22.1, v4.3.1]
steps:
- uses: actions/checkout@v7
with:
@@ -67,16 +73,16 @@ jobs:
env:
HELMFILE_HELM4: ${{ startsWith(matrix.helm-version, 'v4') && '1' || '0' }}
- name: Archive built binaries
if: matrix.helm-version == 'v4.2.4'
if: matrix.helm-version == 'v4.3.1'
run: tar -cvf built-binaries.tar helmfile diff-yamls dyff
- uses: actions/upload-artifact@v7
if: matrix.helm-version == 'v4.2.4'
if: matrix.helm-version == 'v4.3.1'
with:
name: built-binaries-${{ github.run_id }}
path: built-binaries.tar
retention-days: 1
- name: Display built binaries
if: matrix.helm-version == 'v4.2.4'
if: matrix.helm-version == 'v4.3.1'
run: ls -l helmfile diff-yamls dyff
integration_tests:
@@ -96,35 +102,35 @@ jobs:
- helm-version: v3.18.6
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.12
plugin-diff-version: 3.15.13
extra-helmfile-flags: ''
# In case you need to test some optional helmfile features,
# enable it via extra-helmfile-flags below.
- helm-version: v3.18.6
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.12
plugin-diff-version: 3.15.13
extra-helmfile-flags: '--enable-live-output'
- helm-version: v3.21.4
- helm-version: v3.22.1
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.12
plugin-diff-version: 3.15.13
extra-helmfile-flags: ''
- helm-version: v3.21.4
- helm-version: v3.22.1
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.12
plugin-diff-version: 3.15.13
extra-helmfile-flags: '--enable-live-output'
# Helmfile now supports both Helm 3.x and Helm 4.x
- helm-version: v4.2.4
- helm-version: v4.3.1
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.12
plugin-diff-version: 3.15.13
extra-helmfile-flags: ''
- helm-version: v4.2.4
- helm-version: v4.3.1
kustomize-version: v5.8.0
plugin-secrets-version: 4.7.4
plugin-diff-version: 3.15.12
plugin-diff-version: 3.15.13
extra-helmfile-flags: '--enable-live-output'
steps:
- uses: actions/checkout@v7
@@ -146,8 +152,6 @@ jobs:
- uses: actions/download-artifact@v8
with:
name: built-binaries-${{ github.run_id }}
- name: install semver
run: go install github.com/ffurrer2/semver/v2/cmd/semver@v2.12.0
- name: Extract tar to get built binaries
run: tar -xvf built-binaries.tar
- name: Display built binaries
+21
View File
@@ -45,3 +45,24 @@ jobs:
with:
version: latest
args: release --clean ${{ env.SNAPSHOT }}
# Publish a signed packslip (https://packslip.dev) listing each archive's
# digest, the helmfile executable inside it, its shell completions, and the
# skill in skills/helmfile, signed with this workflow's identity so
# installers can verify a download without a key this project must hold.
packslip:
needs: goreleaser
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: jdx/packslip@v1
with:
download: helmfile_*.tar.gz
bin: helmfile
resources: |
completion/bash,zsh,fish,powershell=exec:helmfile completion {shell}
skill/helmfile=repo:skills/helmfile
+1
View File
@@ -49,6 +49,7 @@ linters:
goconst:
min-len: 3
min-occurrences: 8
ignore-tests: true
gocritic:
settings:
captLocal:
+5 -1
View File
@@ -4,7 +4,7 @@
### Essential Setup
```bash
# Check Go version (requires 1.26.2+)
# Check Go version (requires 1.27.1+)
go version
# Check Helm dependency (required at runtime)
@@ -39,6 +39,10 @@ make fmt
# Run golangci-lint
golangci-lint run
# Detect outdated Go patterns (fails if `go fix` would rewrite anything;
# enforced in CI)
make check-modernize
```
### Testing Commands
+7
View File
@@ -2,7 +2,14 @@
### Added
- Add opt-in OpenTelemetry tracing and metrics (`--otel-tracing` / `HELMFILE_OTEL_TRACING`, experimental): command/state-load/release/hook spans with one span per helm subprocess, plus `helmfile.helm.exec.duration`, `helmfile.release.duration`, and `helmfile.release.count` metrics (per-release dimensions opt-in via `HELMFILE_OTEL_METRICS_PER_RELEASE`), exported via standard `OTEL_*` environment variables. See [docs/otel.md](docs/otel.md) and the [design proposal](docs/proposals/otel-tracing.md) (#2767)
- Add support for `conditionTemplate` and allow `condition` to be set directly to `true` or `false`.
- Add `--allow-failed-releases` global flag to continue preparing charts for the remaining releases when chart preparation fails for a release; failed releases are skipped and all failures are reported at the end (#2616)
- Add the `dir=` selector for path-based release filtering and sub-helmfile traversal skip (#2609)
### Deprecated
- The label key `dir` is now reserved for the `dir=` selector. User-defined `dir` labels in `commonLabels` or release `labels` log a warning and are ignored for selector matching; they will be rejected with an error in a future release (#2609)
## [1.4.1] - 2026-03-03
+5 -5
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder
RUN apk add --no-cache make git
WORKDIR /workspace/helmfile
@@ -30,7 +30,7 @@ ENV HELM_CONFIG_HOME="${HELM_CONFIG_HOME}"
ARG HELM_DATA_HOME="${HOME}/.local/share/helm"
ENV HELM_DATA_HOME="${HELM_DATA_HOME}"
ARG HELM_VERSION="v4.2.4"
ARG HELM_VERSION="v4.3.1"
ENV HELM_VERSION="${HELM_VERSION}"
ENV HELM_BIN="/usr/local/bin/helm"
ARG HELM_LOCATION="https://get.helm.sh"
@@ -39,8 +39,8 @@ RUN set -x && \
curl --retry 5 --retry-connrefused -LO "${HELM_LOCATION}/${HELM_FILENAME}" && \
echo Verifying ${HELM_FILENAME}... && \
case ${TARGETPLATFORM} in \
"linux/amd64") HELM_SHA256="c306b46f719b0a4da32d0f78ee21bf90ce8d602f15b22ab753f0674d1670a7f3" ;; \
"linux/arm64") HELM_SHA256="564de2191b881e9f71b5606b25345821ea1682f06ab90499d3ab22b530176da1" ;; \
"linux/amd64") HELM_SHA256="0403a88ae20913d11b10a2467b50087721fea7581736da6b9d3992f5357fe54b" ;; \
"linux/arm64") HELM_SHA256="8cf8f66ecbc252ac8f7f06cca68aba88508ddfde3a6a34feb1678ad1694b4f88" ;; \
esac && \
echo "${HELM_SHA256} ${HELM_FILENAME}" | sha256sum -c && \
echo Extracting ${HELM_FILENAME}... && \
@@ -95,7 +95,7 @@ RUN set -x && \
[ "$(age-keygen --version)" = "${AGE_VERSION}" ]
ARG HELM_SECRETS_VERSION="4.7.4"
RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.12 --verify=false && \
RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.13 --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \
+5 -5
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder
RUN apk add --no-cache make git
WORKDIR /workspace/helmfile
@@ -38,7 +38,7 @@ ENV HELM_CONFIG_HOME="${HELM_CONFIG_HOME}"
ARG HELM_DATA_HOME="${HOME}/.local/share/helm"
ENV HELM_DATA_HOME="${HELM_DATA_HOME}"
ARG HELM_VERSION="v4.2.4"
ARG HELM_VERSION="v4.3.1"
ENV HELM_VERSION="${HELM_VERSION}"
ENV HELM_BIN="/usr/local/bin/helm"
ARG HELM_LOCATION="https://get.helm.sh"
@@ -47,8 +47,8 @@ RUN set -x && \
curl --retry 5 --retry-connrefused -LO "${HELM_LOCATION}/${HELM_FILENAME}" && \
echo Verifying ${HELM_FILENAME}... && \
case ${TARGETPLATFORM} in \
"linux/amd64") HELM_SHA256="c306b46f719b0a4da32d0f78ee21bf90ce8d602f15b22ab753f0674d1670a7f3" ;; \
"linux/arm64") HELM_SHA256="564de2191b881e9f71b5606b25345821ea1682f06ab90499d3ab22b530176da1" ;; \
"linux/amd64") HELM_SHA256="0403a88ae20913d11b10a2467b50087721fea7581736da6b9d3992f5357fe54b" ;; \
"linux/arm64") HELM_SHA256="8cf8f66ecbc252ac8f7f06cca68aba88508ddfde3a6a34feb1678ad1694b4f88" ;; \
esac && \
echo "${HELM_SHA256} ${HELM_FILENAME}" | sha256sum -c && \
echo Extracting ${HELM_FILENAME}... && \
@@ -104,7 +104,7 @@ RUN set -x && \
[ "$(age-keygen --version)" = "${AGE_VERSION}" ]
ARG HELM_SECRETS_VERSION="4.7.4"
RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.12 --verify=false && \
RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.13 --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \
+5 -5
View File
@@ -1,4 +1,4 @@
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS builder
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS builder
RUN apk add --no-cache make git
WORKDIR /workspace/helmfile
@@ -38,7 +38,7 @@ ENV HELM_CONFIG_HOME="${HELM_CONFIG_HOME}"
ARG HELM_DATA_HOME="${HOME}/.local/share/helm"
ENV HELM_DATA_HOME="${HELM_DATA_HOME}"
ARG HELM_VERSION="v4.2.4"
ARG HELM_VERSION="v4.3.1"
ENV HELM_VERSION="${HELM_VERSION}"
ENV HELM_BIN="/usr/local/bin/helm"
ARG HELM_LOCATION="https://get.helm.sh"
@@ -47,8 +47,8 @@ RUN set -x && \
curl --retry 5 --retry-connrefused -LO "${HELM_LOCATION}/${HELM_FILENAME}" && \
echo Verifying ${HELM_FILENAME}... && \
case ${TARGETPLATFORM} in \
"linux/amd64") HELM_SHA256="c306b46f719b0a4da32d0f78ee21bf90ce8d602f15b22ab753f0674d1670a7f3" ;; \
"linux/arm64") HELM_SHA256="564de2191b881e9f71b5606b25345821ea1682f06ab90499d3ab22b530176da1" ;; \
"linux/amd64") HELM_SHA256="0403a88ae20913d11b10a2467b50087721fea7581736da6b9d3992f5357fe54b" ;; \
"linux/arm64") HELM_SHA256="8cf8f66ecbc252ac8f7f06cca68aba88508ddfde3a6a34feb1678ad1694b4f88" ;; \
esac && \
echo "${HELM_SHA256} ${HELM_FILENAME}" | sha256sum -c && \
echo Extracting ${HELM_FILENAME}... && \
@@ -104,7 +104,7 @@ RUN set -x && \
[ "$(age-keygen --version)" = "${AGE_VERSION}" ]
ARG HELM_SECRETS_VERSION="4.7.4"
RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.12 --verify=false && \
RUN helm plugin install https://github.com/databus23/helm-diff --version v3.15.13 --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-${HELM_SECRETS_VERSION}.tgz --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-getter-${HELM_SECRETS_VERSION}.tgz --verify=false && \
helm plugin install https://github.com/jkroepke/helm-secrets/releases/download/v${HELM_SECRETS_VERSION}/secrets-post-renderer-${HELM_SECRETS_VERSION}.tgz --verify=false && \
+17
View File
@@ -172,6 +172,23 @@ releases:
trackLogs: true # Show pod logs during tracking
```
### trackLogsUntilReady
Limit pod log output to startup while continuing readiness and failure tracking:
```yaml
releases:
- name: my-app
trackMode: kubedog
trackTimeout: 600
trackLogs: true
trackLogsUntilReady: true
```
Each pod stops printing routine logs once it first becomes ready, even while other replicas are still starting. Logs captured before readiness are flushed. The same pod does not resume routine log output if it later becomes unready or fails. Job logging is unchanged. The default is `false`, which keeps full streaming when `trackLogs` is enabled. This option does not change `trackFailedLogs` when `trackLogs` is disabled.
The equivalent CLI flags are `--track-logs --track-logs-until-ready` with `--track-mode kubedog`.
### trackKinds / skipKinds
Control which resource types to track:
+6
View File
@@ -38,6 +38,12 @@ check:
go vet ${PKGS}
.PHONY: check
# check-modernize fails (exit 1) when any `go fix` modernizer would rewrite code,
# i.e. the codebase contains outdated Go patterns. Run `go fix ./...` to fix, then `make fmt`.
check-modernize:
go fix -diff ./...
.PHONY: check-modernize
build-test-tools:
go build test/diff-yamls/diff-yamls.go
curl --progress-bar --location https://github.com/homeport/dyff/releases/download/v1.5.6/dyff_1.5.6_linux_amd64.tar.gz | tar -xzf - -C `pwd` dyff
+5 -1
View File
@@ -9,13 +9,14 @@ import (
// NewApplyCmd returns apply subcmd
func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command {
applyOptions := &config.ApplyOptions{}
applyOptions := config.NewApplyOptions()
cmd := &cobra.Command{
Use: "apply",
Short: "Apply all resources from state file only when there are changes",
RunE: func(cmd *cobra.Command, args []string) error {
applyImpl := config.NewApplyImpl(globalCfg, applyOptions)
applyImpl.Cmd = cmd
err := config.NewCLIConfigImpl(applyImpl.GlobalImpl)
if err != nil {
@@ -74,7 +75,10 @@ func NewApplyCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.StringVar(&applyOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default), 'helm-legacy' (Helm v4 only), or 'kubedog'")
f.IntVar(&applyOptions.TrackTimeout, "track-timeout", 0, `Timeout in seconds for kubedog tracking (0 to use default 300s timeout)`)
f.BoolVar(&applyOptions.TrackLogs, "track-logs", false, "Enable log streaming with kubedog tracking (all pods)")
f.BoolVar(&applyOptions.TrackLogsUntilReady, "track-logs-until-ready", false, "Stop pod log streaming when each pod becomes ready (requires --track-logs)")
f.BoolVar(&applyOptions.TrackFailedLogs, "track-failed-logs", false, "Enable log streaming with kubedog tracking, but only emit logs for pods that enter a failed state. Overridden by --track-logs when both are set")
f.DurationVar(&applyOptions.TrackLogsInterval, "track-logs-interval", applyOptions.TrackLogsInterval,
"Interval between kubedog log output updates (minimum 1s); overrides trackLogsInterval")
f.IntVar(&applyOptions.HelmStuckGrace, "helm-stuck-grace", 0, "When using --track-mode kubedog: if the cluster confirms all tracked resources have converged but the helm subprocess is still running, wait this many seconds before sending SIGINT to helm. Recovers from helm v4 hook waiter wedges. May leave the release secret in pending-install state requiring manual cleanup. 0 disables.")
f.BoolVar(&applyOptions.TrackFailOnError, "track-fail-on-error", false, "Fail with non-zero exit code when kubedog tracking fails")
f.StringVar(&applyOptions.Description, "description", "", `Set description for all releases. If set, overridesdescriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`)
+97 -5
View File
@@ -4,9 +4,12 @@ import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.opentelemetry.io/otel/attribute"
"go.szostok.io/version/extension"
"go.uber.org/zap"
@@ -15,8 +18,11 @@ import (
"github.com/helmfile/helmfile/pkg/config"
"github.com/helmfile/helmfile/pkg/envvar"
"github.com/helmfile/helmfile/pkg/errors"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/runtime"
"github.com/helmfile/helmfile/pkg/state"
"github.com/helmfile/helmfile/pkg/telemetry"
)
var logger *zap.SugaredLogger
@@ -24,8 +30,7 @@ var globalUsage = "Declaratively deploy your Kubernetes manifests, Kustomize con
func toCLIError(g *config.GlobalImpl, err error) error {
if err != nil {
var exitErr helmexec.ExitError
if stderrors.As(err, &exitErr) {
if exitErr, ok := stderrors.AsType[helmexec.ExitError](err); ok {
return errors.NewExitError(exitErr.Error(), exitErr.ExitStatus())
}
switch e := err.(type) {
@@ -75,6 +80,19 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
}
logger = helmexec.NewLogger(logOut, logLevel)
globalConfig.SetLogger(logger)
// OpenTelemetry tracing (experimental). Setup and StartCommandSpan
// are no-ops when tracing is disabled, so the default path is
// unchanged. Configuration beyond the on/off switch comes from the
// standard OTEL_* environment variables.
cmdName := "helmfile " + c.Name()
telemetry.Setup(c.Context(), telemetry.Options{
Enabled: globalImpl.OtelTracing(),
Version: version.Version(),
Logger: logger,
})
telemetry.StartCommandSpan(cmdName, commandSpanAttributes(cmdName, globalImpl)...)
return nil
},
}
@@ -83,6 +101,10 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
// Set the global options for the root command.
setGlobalOptionsForRootCmd(flags, globalConfig)
if err := cmd.RegisterFlagCompletionFunc("selector", selectorFlagCompletion(globalConfig)); err != nil {
return nil, fmt.Errorf("registering selector flag completion: %w", err)
}
flags.ParseErrorsAllowlist.UnknownFlags = true
// when set environment HELMFILE_UPGRADE_NOTICE_DISABLED any value, skip upgrade notice.
@@ -121,16 +143,82 @@ func NewRootCmd(globalConfig *config.GlobalOptions) (*cobra.Command, error) {
return cmd, nil
}
// commandSpanAttributes builds the root-span attributes from the resolved
// global options. Values that can be overridden per release appear again on
// child spans; the service identity (service.name/service.version) lives on
// the OTel resource, not on spans.
func commandSpanAttributes(cmdName string, g *config.GlobalImpl) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.String("helmfile.command", cmdName),
// FileOrDir may be a remote go-getter reference with credentials.
attribute.String("helmfile.file", helmexec.RedactedRef(g.FileOrDir())),
attribute.String("helmfile.environment", g.Env()),
attribute.StringSlice("helmfile.selectors", g.Selectors()),
}
}
// selectorFlagCompletion provides shell completion for `-l/--selector`.
// When the user types a value whose last comma-separated condition starts with
// `dir=`, this enumerates directories matching the partial path after `=`,
// relative to the root helmfile directory like the selector itself.
// Other selector keys/values are too open-ended to enumerate, so the function
// suppresses default file completion in that case.
func selectorFlagCompletion(globalOptions *config.GlobalOptions) cobra.CompletionFunc {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
const dirPrefix = state.DirLabel + "="
groups := strings.Split(toComplete, ",")
last := groups[len(groups)-1]
if !strings.HasPrefix(last, dirPrefix) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
leading := strings.Join(groups[:len(groups)-1], ",")
if leading != "" {
leading += ","
}
partial := filepath.FromSlash(strings.TrimPrefix(last, dirPrefix))
if partial != "" && !filepath.IsLocal(partial) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
fileOrDir := config.NewGlobalImpl(globalOptions).FileOrDir()
rootDir, err := app.DirSelectorRoot(filesystem.DefaultFileSystem(), fileOrDir)
if err != nil || rootDir == "" {
return nil, cobra.ShellCompDirectiveNoFileComp
}
scanDir, matchPrefix := filepath.Split(partial)
entries, err := os.ReadDir(filepath.Join(rootDir, scanDir))
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
suggestions := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), matchPrefix) {
continue
}
suggestions = append(suggestions, leading+dirPrefix+filepath.ToSlash(filepath.Join(scanDir, entry.Name())))
}
return suggestions, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
}
func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalOptions) {
fs.StringVarP(&globalOptions.HelmBinary, "helm-binary", "b", "", fmt.Sprintf(`Path to the helm binary. Overrides "HELMFILE_HELM_BINARY" OS environment variable when specified (default %q)`, app.DefaultHelmBinary))
fs.StringVarP(&globalOptions.KustomizeBinary, "kustomize-binary", "k", "", fmt.Sprintf(`Path to the kustomize binary. Overrides "HELMFILE_KUSTOMIZE_BINARY" OS environment variable when specified (default %q)`, app.DefaultKustomizeBinary))
fs.StringVarP(&globalOptions.File, "file", "f", "", "load config from file or directory. defaults to \"`helmfile.yaml`\" or \"helmfile.yaml.gotmpl\" or \"helmfile.d\" (means \"helmfile.d/*.yaml\" or \"helmfile.d/*.yaml.gotmpl\") in this preference. Specify - to load the config from the standard input.")
fs.StringVarP(&globalOptions.Environment, "environment", "e", "", `specify the environment name. Overrides "HELMFILE_ENVIRONMENT" OS environment variable when specified. defaults to "default"`)
fs.StringArrayVar(&globalOptions.StateValuesSet, "state-values-set", nil, "set state values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2). Used to override .Values within the helmfile template (not values template).")
fs.StringArrayVar(&globalOptions.StateValuesSetString, "state-values-set-string", nil, "set state STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2). Used to override .Values within the helmfile template (not values template).")
fs.StringArrayVar(&globalOptions.StateValuesSet, "state-values-set", nil, "set state values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2; escape a comma in a value with \\,). Used to override .Values within the helmfile template (not values template).")
fs.StringArrayVar(&globalOptions.StateValuesSetString, "state-values-set-string", nil, "set state STRING values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2; escape a comma in a value with \\,). Used to override .Values within the helmfile template (not values template).")
fs.StringArrayVar(&globalOptions.StateValuesFile, "state-values-file", nil, "specify state values in a YAML file. Used to override .Values within the helmfile template (not values template).")
fs.BoolVar(&globalOptions.SkipDeps, "skip-deps", false, `skip running "helm repo update" and "helm dependency build"`)
fs.BoolVar(&globalOptions.SkipRefresh, "skip-refresh", false, `skip running "helm repo update"`)
fs.BoolVar(&globalOptions.AllowFailedReleases, "allow-failed-releases", false, `continue preparing charts for other releases when chart preparation fails for a release; failed releases are skipped and all failures are reported at the end`)
fs.BoolVar(&globalOptions.StripArgsValuesOnExitError, "strip-args-values-on-exit-error", true, `Strip the potential secret values of the helm command args contained in a helmfile error message`)
fs.BoolVar(&globalOptions.DisableForceUpdate, "disable-force-update", false, `do not force helm repos to update when executing "helm repo add" (Helm 3 only)`)
fs.BoolVar(&globalOptions.EnforcePluginVerification, "enforce-plugin-verification", false, `fail plugin installation if verification is not supported (for security purposes)`)
@@ -154,7 +242,8 @@ func setGlobalOptionsForRootCmd(fs *pflag.FlagSet, globalOptions *config.GlobalO
fs.StringArrayVarP(&globalOptions.Selector, "selector", "l", nil, `Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar.
A release must match all labels in a group in order to be used. Multiple groups can be specified at once.
"--selector tier=frontend,tier!=proxy --selector tier=backend" will match all frontend, non-proxy releases AND all backend releases.
The name of a release can be used as a label: "--selector name=myrelease"`)
The name of a release can be used as a label: "--selector name=myrelease".
The "dir" key matches by directory prefix of the defining helmfile and skips non-matching sub-helmfiles; see https://helmfile.readthedocs.io/en/latest/#the-dir-selector`)
fs.BoolVar(&globalOptions.AllowNoMatchingRelease, "allow-no-matching-release", false, `Do not exit with an error code if the provided selector has no matching releases.`)
fs.BoolVar(&globalOptions.EnableLiveOutput, "enable-live-output", globalOptions.EnableLiveOutput, `Show live output from the Helm binary Stdout/Stderr into Helmfile own Stdout/Stderr.
It only applies for the Helm CLI commands, Stdout/Stderr for Hooks are still displayed only when it's execution finishes.`)
@@ -163,6 +252,9 @@ It only applies for the Helm CLI commands, Stdout/Stderr for Hooks are still dis
Useful when file order matters for dependencies (e.g., databases before applications).
When processing multiple files, paths are resolved without changing the process working directory,
so relative environment variables like KUBECONFIG work correctly.`)
fs.BoolVar(&globalOptions.OtelTracing, "otel-tracing", globalOptions.OtelTracing, `Enable OpenTelemetry tracing and metrics (experimental).
Configure exporters with standard OTEL_* environment variables (e.g. OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER).
Overrides "HELMFILE_OTEL_TRACING" OS environment variable when specified. See docs/otel.md`)
// avoid 'pflag: help requested' error (#251)
fs.BoolP("help", "h", false, "help for helmfile")
}
+151
View File
@@ -1,10 +1,15 @@
package cmd
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/helmfile/helmfile/pkg/config"
"github.com/helmfile/helmfile/pkg/errors"
@@ -70,3 +75,149 @@ func TestToCLIError(t *testing.T) {
})
}
}
func TestRootCmdRegistersOtelTracingFlag(t *testing.T) {
rootCmd, err := NewRootCmd(&config.GlobalOptions{})
require.NoError(t, err)
flag := rootCmd.PersistentFlags().Lookup("otel-tracing")
require.NotNil(t, flag, "--otel-tracing flag should be registered")
assert.Equal(t, "bool", flag.Value.Type())
assert.Equal(t, "false", flag.DefValue)
assert.Contains(t, flag.Usage, "HELMFILE_OTEL_TRACING")
}
func TestTrackLogsUntilReadyFlag(t *testing.T) {
for _, command := range []string{"sync", "apply"} {
t.Run(command, func(t *testing.T) {
rootCmd, err := NewRootCmd(&config.GlobalOptions{})
require.NoError(t, err)
cmd, _, err := rootCmd.Find([]string{command})
require.NoError(t, err)
flag := cmd.Flags().Lookup("track-logs-until-ready")
require.NotNil(t, flag)
assert.Equal(t, "false", flag.DefValue)
require.NoError(t, cmd.ParseFlags([]string{"--track-logs", "--track-logs-until-ready"}))
enabled, err := cmd.Flags().GetBool("track-logs-until-ready")
require.NoError(t, err)
assert.True(t, enabled)
})
}
}
func TestSelectorFlagCompletion_NonDirKey(t *testing.T) {
// For any selector value that is not a dir= prefix, completion should
// suppress file completion and return no suggestions.
cases := []string{"", "name=", "name=foo", "tier=backend,name", "dir"}
for _, in := range cases {
t.Run(in, func(t *testing.T) {
suggestions, dir := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, in)
assert.Nil(t, suggestions)
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, dir)
})
}
}
func TestSelectorFlagCompletion_DirEnumeratesDirectories(t *testing.T) {
// Set up a temp tree with two subdirs and one file. Completion against
// `dir=` should suggest only the subdirs, prefixed with `dir=`.
root := t.TempDir()
for _, sub := range []string{"apps", "infra"} {
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
}
require.NoError(t, os.WriteFile(filepath.Join(root, "helmfile.yaml"), []byte("releases: []"), 0o644))
t.Chdir(root)
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, "dir=")
assert.ElementsMatch(t, []string{"dir=apps", "dir=infra"}, suggestions)
}
func TestSelectorFlagCompletion_DirPartialPath(t *testing.T) {
root := t.TempDir()
for _, sub := range []string{"apps/opencloud", "apps/openproject", "apps/xwiki"} {
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
}
t.Chdir(root)
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, "dir=apps/op")
assert.ElementsMatch(t, []string{"dir=apps/opencloud", "dir=apps/openproject"}, suggestions)
}
func TestSelectorFlagCompletion_DirCarriesOverPriorGroups(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "apps"), 0o755))
t.Chdir(root)
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, "name=foo,dir=")
assert.ElementsMatch(t, []string{"name=foo,dir=apps"}, suggestions)
}
func TestSelectorFlagCompletion_UnmatchableValuesReturnNoSuggestions(t *testing.T) {
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "apps"), 0o755))
t.Chdir(filepath.Join(root, "apps"))
for _, in := range []string{"dir=does/not/exist/", "dir=" + filepath.ToSlash(root) + "/", "dir=../", "dir=../ap"} {
t.Run(in, func(t *testing.T) {
suggestions, dir := selectorFlagCompletion(&config.GlobalOptions{})(nil, nil, in)
assert.Nil(t, suggestions)
assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, dir)
})
}
}
func TestSelectorFlagCompletion_DirIsRelativeToRootHelmfile(t *testing.T) {
root := t.TempDir()
for _, sub := range []string{"deploy/apps/opencloud", "deploy/infra", "unrelated"} {
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
}
require.NoError(t, os.WriteFile(filepath.Join(root, "deploy", "helmfile.yaml"), []byte("releases: []"), 0o644))
t.Chdir(root)
tests := []struct {
name string
file string
envFile string
toComplete string
want []string
}{
{name: "file flag", file: "deploy/helmfile.yaml", toComplete: "dir=", want: []string{"dir=apps", "dir=infra"}},
{name: "file flag with nested partial", file: "deploy/helmfile.yaml", toComplete: "dir=apps/", want: []string{"dir=apps/opencloud"}},
{name: "directory flag", file: "deploy", toComplete: "dir=", want: []string{"dir=apps", "dir=infra"}},
{name: "absolute file flag", file: filepath.Join(root, "deploy", "helmfile.yaml"), toComplete: "dir=i", want: []string{"dir=infra"}},
{name: "file from environment", envFile: "deploy/helmfile.yaml", toComplete: "dir=", want: []string{"dir=apps", "dir=infra"}},
{name: "no file uses working directory", toComplete: "dir=", want: []string{"dir=deploy", "dir=unrelated"}},
{name: "remote file has no local root", file: "git::https://github.com/example/repo.git@helmfile.yaml?ref=main", toComplete: "dir=", want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("HELMFILE_FILE_PATH", tt.envFile)
suggestions, _ := selectorFlagCompletion(&config.GlobalOptions{File: tt.file})(nil, nil, tt.toComplete)
assert.ElementsMatch(t, tt.want, suggestions)
})
}
}
func TestSelectorFlagCompletion_ThroughRootCmdHonorsFileFlag(t *testing.T) {
root := t.TempDir()
for _, sub := range []string{"deploy/apps", "unrelated"} {
require.NoError(t, os.MkdirAll(filepath.Join(root, sub), 0o755))
}
t.Chdir(root)
t.Setenv("HELMFILE_FILE_PATH", "")
rootCmd, err := NewRootCmd(&config.GlobalOptions{})
require.NoError(t, err)
var out bytes.Buffer
rootCmd.SetOut(&out)
rootCmd.SetArgs([]string{cobra.ShellCompNoDescRequestCmd, "--file", "deploy/helmfile.yaml", "sync", "--selector", "dir="})
require.NoError(t, rootCmd.Execute())
assert.Contains(t, out.String(), "dir=apps\n")
assert.NotContains(t, out.String(), "dir=deploy")
assert.NotContains(t, out.String(), "dir=unrelated")
}
+5
View File
@@ -16,6 +16,8 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command {
Short: "Sync releases defined in state file",
RunE: func(cmd *cobra.Command, args []string) error {
syncImpl := config.NewSyncImpl(globalCfg, syncOptions)
syncImpl.Cmd = cmd
err := config.NewCLIConfigImpl(syncImpl.GlobalImpl)
if err != nil {
return err
@@ -58,7 +60,10 @@ func NewSyncCmd(globalCfg *config.GlobalImpl) *cobra.Command {
f.StringVar(&syncOptions.TrackMode, "track-mode", "", "Track mode for releases: 'helm' (default), 'helm-legacy' (Helm v4 only), or 'kubedog'")
f.IntVar(&syncOptions.TrackTimeout, "track-timeout", 0, `Timeout in seconds for kubedog tracking (0 to use default 300s timeout)`)
f.BoolVar(&syncOptions.TrackLogs, "track-logs", false, "Enable log streaming with kubedog tracking (all pods)")
f.BoolVar(&syncOptions.TrackLogsUntilReady, "track-logs-until-ready", false, "Stop pod log streaming when each pod becomes ready (requires --track-logs)")
f.BoolVar(&syncOptions.TrackFailedLogs, "track-failed-logs", false, "Enable log streaming with kubedog tracking, but only emit logs for pods that enter a failed state. Overridden by --track-logs when both are set")
f.DurationVar(&syncOptions.TrackLogsInterval, "track-logs-interval", syncOptions.TrackLogsInterval,
"Interval between kubedog log output updates (minimum 1s); overrides trackLogsInterval")
f.IntVar(&syncOptions.HelmStuckGrace, "helm-stuck-grace", 0, "When using --track-mode kubedog: if the cluster confirms all tracked resources have converged but the helm subprocess is still running, wait this many seconds before sending SIGINT to helm. Recovers from helm v4 hook waiter wedges. May leave the release secret in pending-install state requiring manual cleanup. 0 disables.")
f.BoolVar(&syncOptions.TrackFailOnError, "track-fail-on-error", false, "Fail with non-zero exit code when kubedog tracking fails")
f.StringVar(&syncOptions.Description, "description", "", `Set description for all releases. If set, overrides descriptions in helmfile.yaml. Will be passed to "helm upgrade --description"`)
+35 -2
View File
@@ -26,14 +26,47 @@ releases:
Or use command-line flags:
```bash
helmfile apply --track-mode kubedog --track-timeout 300 --track-logs
helmfile apply --track-mode kubedog --track-timeout 300 --track-logs --track-logs-interval 1s
```
### Configuration Options
- **`trackMode`**: Set to `kubedog` to enable kubedog tracking, or `helm-legacy` to use Helm v4's legacy wait mode (default: `helm`)
- **`trackTimeout`**: Timeout in seconds for tracking resources (default: 300)
- **`trackLogs`**: Enable real-time log streaming from tracked resources
- **`trackLogs`**: Print logs from tracked resources during deployment
- **`trackLogsInterval`**: Interval between log output updates, as a duration such as `1s` or `30s` (default: `10s`; minimum: `1s`)
- **`trackLogsUntilReady`**: With `trackLogs: true`, stop routine pod logs once each pod first becomes ready (default: false)
- **`trackFailedLogs`**: Print collected logs only for pods that fail during deployment
To see logs only for failed pods, set `trackFailedLogs: true` on a release or use `--track-failed-logs` with `helmfile apply` or `helmfile sync`. If both log options are enabled, logs from all tracked pods are printed.
With kubedog tracking and either `trackLogs` or `trackFailedLogs` enabled, `helmfile apply` and `helmfile sync` print collected logs every 10 seconds by default. Set `trackLogsInterval` on a release or use `--track-logs-interval` to change this interval (for example, `1s` above). An explicitly supplied command-line flag overrides the release setting; otherwise, the release setting takes precedence over the `10s` default. The minimum is `1s`. Deployment progress is checked for changes every 10 seconds.
### Startup-Only Pod Logs
Use `trackLogsUntilReady: true` to show startup logs without continuing to print application traffic from ready pods while other replicas are still starting:
```yaml
releases:
- name: myapp
chart: ./charts/myapp
trackMode: kubedog
trackTimeout: 600
trackLogs: true
trackLogsUntilReady: true
```
Or use command-line flags:
```bash
helmfile apply --track-mode kubedog --track-timeout 600 --track-logs --track-logs-until-ready
```
Logs captured before readiness are flushed. Log output remains limited to startup if the same pod later becomes unready or fails; readiness and failure tracking continue for every pod. Job logs retain their existing behavior.
Kubernetes readiness timestamps have second precision, so logs from the readiness second are retained to avoid losing startup output.
The option defaults to `false`, so `trackLogs: true` continues printing logs throughout tracking. It only applies when `trackLogs` is enabled; `trackFailedLogs: true` alone keeps its existing failed-only behavior. When both `trackLogs` and `trackFailedLogs` are enabled, `trackLogs` takes precedence.
### Track Modes
+30 -1
View File
@@ -54,12 +54,16 @@ Flags:
--log-level string Set log level. Overrides "HELMFILE_LOG_LEVEL" OS environment variable when specified (default "info")
-n, --namespace string Set namespace. Overrides "HELMFILE_NAMESPACE" OS environment variable when specified. Uses the namespace set in the context by default, and is available in templates as {{ .Namespace }}
--no-color Output without color. Overrides "HELMFILE_NO_COLOR" and "NO_COLOR" OS environment variables when specified
--otel-tracing Enable OpenTelemetry tracing (experimental).
Configure the exporter with standard OTEL_* environment variables (e.g. OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_TRACES_EXPORTER).
Overrides "HELMFILE_OTEL_TRACING" OS environment variable when specified. See docs/otel.md
-q, --quiet Silence output. Equivalent to log-level warn. Overrides "HELMFILE_QUIET" OS environment variable when specified
--repo-retries int Number of times to retry "helm repo add/update" and "helm registry login" on failure, with exponential backoff (1s, 2s, 4s, ..., capped at 30s). Set to 0 to disable retries. Overrides "HELMFILE_REPO_RETRIES" OS environment variable when specified
-l, --selector stringArray Only run using the releases that match labels. Labels can take the form of foo=bar or foo!=bar.
A release must match all labels in a group in order to be used. Multiple groups can be specified at once.
"--selector tier=frontend,tier!=proxy --selector tier=backend" will match all frontend, non-proxy releases AND all backend releases.
The name of a release can be used as a label: "--selector name=myrelease"
The name of a release can be used as a label: "--selector name=myrelease".
The "dir" key matches by directory prefix of the defining helmfile and skips non-matching sub-helmfiles; see https://helmfile.readthedocs.io/en/latest/#the-dir-selector
--skip-deps skip running "helm repo update" and "helm dependency build"
--state-values-file stringArray specify state values in a YAML file. Used to override .Values within the helmfile template (not values template).
--state-values-set stringArray set state values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2). Used to override .Values within the helmfile template (not values template).
@@ -73,6 +77,30 @@ Use "helmfile [command] --help" for more information about a command.
**Note:** Each command has its own specific flags. Use `helmfile [command] --help` to see command-specific options. For example, `helmfile sync --help` shows operational flags like `--timeout`, `--wait`, and `--wait-for-jobs`.
### State value overrides
Use `--state-values-set` to override `.Values` within the Helmfile template. Separate
assignments with commas, and escape a comma inside a value with `\,`:
```bash
helmfile --state-values-set 'message=hello\,world,replicas=2' build
```
This sets `message` to the string `hello,world` and `replicas` to the number `2`.
Single quotes preserve the backslash when passing the argument through the shell.
Backslashes before other characters and at the end of a value are preserved.
`--state-values-set-string` accepts the same syntax but keeps every value as a
string without type conversion. Its values may additionally be wrapped in
single or double quotes, which allows commas without escaping:
```bash
helmfile --state-values-set-string 'zone="zone1,zone2",imageTag=1.23.3' build
```
Malformed assignments (missing `=`, empty key, or a trailing comma) are reported
as errors instead of being partially applied.
### init
The `helmfile init` sub-command checks the dependencies required for helmfile operation, such as `helm`, `helm diff plugin`, `helm secrets plugin`, `helm helm-git plugin`, `helm s3 plugin`. When it does not exist or the version is too low, it can be installed automatically.
@@ -550,6 +578,7 @@ The following global flags are also available but not shown in the main help out
| Flag | Default | Description |
|------|---------|-------------|
| `--kubeconfig` | `""` | Use a particular kubeconfig file |
| `--allow-failed-releases` | false | Continue preparing charts for other releases when chart preparation fails for a release; failed releases are skipped and all failures are reported at the end |
| `--skip-refresh` | false | Skip running `helm repo update` (lighter than `--skip-deps` which also skips dependency build) |
| `--enforce-plugin-verification` | false | Fail plugin installation if verification is not supported |
| `--oci-plain-http` | false | Use plain HTTP for OCI registries (required for local/insecure registries in Helm 4) |
+31 -3
View File
@@ -116,7 +116,8 @@ helmDefaults:
waitForJobs: true
# time in seconds to wait for any individual Kubernetes operation (like Jobs for hooks, and waits on pod/pvc/svc/deployment readiness) (default 300)
timeout: 600
# performs pods restart for the resource if applicable (default false)
# performs pods restart for the resource if applicable (default false).
# Ignored with a warning on Helm 4, which removed --recreate-pods.
recreatePods: true
# forces resource update through delete/recreate if needed (default false)
force: false
@@ -129,6 +130,22 @@ helmDefaults:
# When set to `true`, skips running `helm dep up` and `helm dep build` on this release's chart.
# Useful when the chart is broken, like seen in https://github.com/roboll/helmfile/issues/1547
skipDeps: false
# When set to `true` (default), resolves an OCI chart's semver constraint
# (e.g. `~1`, `^2.0.0`, `*`, `1.x`, or any version that is not a
# fully-qualified `X.Y.Z` semver — including partial versions like `1` or
# `1.2`, which helm resolves as floating ranges) to a concrete registry tag
# before deriving the on-disk cache path under `$XDG_CACHE_HOME/helmfile`.
# This keeps the cache content-addressable so a newer matching tag is picked
# up on the next run instead of the previously-resolved (now stale) version.
# Set to `false` to preserve the pre-fix behavior of caching under the raw
# constraint string. Resolution is also skipped when skipRefresh is enabled
# (CLI `--skip-refresh`, per-release, or here): helmfile then reuses whatever
# a previous run resolved. Releases with no `version:` at all are also
# unaffected: helm picks the latest tag at pull time and helmfile caches it
# under a version-less path (same as before this setting existed). Exact
# `X.Y.Z` versions (e.g. `1.0.1`) and non-OCI releases are unaffected. See
# issue #2766.
resolveOCIVersions: true
# If set to true, reuses the last release's values and merges them with ones provided in helmfile.
# This attribute, can be overriden in CLI with --reset/reuse-values flag of apply/sync/diff subcommands
reuseValues: false
@@ -238,7 +255,7 @@ releases:
# waitRetries: 3
waitForJobs: true
timeout: 60
recreatePods: true
recreatePods: true # ignored with a warning on Helm 4
force: false
reuseValues: false
# set `false` to uninstall this release on sync. (default true)
@@ -442,6 +459,7 @@ The following `helmDefaults` fields are also available but not shown in the exam
| `enableDNS` | bool | false | Enable DNS lookups when rendering templates |
| `skipCRDs` | bool | false | Skip CRDs during installation |
| `skipRefresh` | bool | false | Skip running `helm dependency up` |
| `resolveOCIVersions` | bool | true | Resolve OCI semver constraints (e.g. `~1`, `^2.0.0`, `*`, `1.x`, and partial versions like `1.2` that helm treats as floating ranges) to concrete registry tags before deriving the shared cache path. Prevents stale cache hits after new matching tags are published. Set to `false` to keep the pre-fix behavior. Skipped when `skipRefresh` is enabled (CLI `--skip-refresh`, per-release, or `helmDefaults`). Only affects OCI releases with a non-exact version. See issue #2766 |
| `atomic` | bool | false | Restore previous state on a failed install/upgrade. On Helm 4+ emits `--rollback-on-failure` (the successor to the deprecated `--atomic`); on older Helm emits `--atomic` |
| `rollbackOnFailure` | bool | false | Restore previous state on a failed install/upgrade via the Helm 4 `--rollback-on-failure` flag. Requires Helm 4 or greater. Mutually exclusive with `atomic` |
| `forceConflicts` | bool | false | Force server-side apply changes against conflicts (Helm 4 only) |
@@ -456,6 +474,12 @@ The following `helmDefaults` fields are also available but not shown in the exam
`condition` controls whether a release is enabled. An empty condition enables the release. A direct `true` or `false` value is treated as a literal boolean and bypasses values lookup. Any other condition must be a values lookup path ending in `.enabled`, such as `vault.enabled`.
#### Continue On Error
`continueOnError` defaults to `false`. When set to `true`, Helmfile keeps processing independent releases after this release fails, but any release that depends on it is skipped with an explicit error such as `release "backend" was skipped because dependency "database" failed`.
This setting only changes Helmfile orchestration. It does not change Helm's per-release rollback behavior (`atomic`, `rollbackOnFailure`, `cleanupOnFail`, `wait`, `waitForJobs`, `trackMode`, hooks, or `reinstallIfForbidden`), and it does not turn a release failure into a successful command. `helmfile sync` and the sync phase of `helmfile apply` still exit with a non-zero status when any release fails. For `apply`, diff and chart-preparation failures remain fatal and are not converted into tolerated release errors.
`conditionTemplate` is evaluated before `condition` is checked. It must render to a boolean value; when both `condition` and `conditionTemplate` are set, the rendered `conditionTemplate` value replaces `condition`. Like other `*Template` fields, `conditionTemplate` is not evaluated by the `list` command.
The following per-release fields are also available:
@@ -475,6 +499,7 @@ The following per-release fields are also available:
| `forceGoGetter` | bool | false | Force go-getter URL parsing for the chart field. Useful when go-getter URL parsing fails unexpectedly |
| `forceNamespace` | string | | Force namespace on all K8s resources rendered by the chart, even when the template doesn't use `{{ .Namespace }}`. Use with caution |
| `skipRefresh` | bool | false | Per-release skip for `helm dependency up` |
| `resolveOCIVersions` | bool | inherited | Per-release override of the `helmDefaults.resolveOCIVersions` setting |
| `disableAutoDetectedKubeVersionForDiff` | bool | false | Disable auto-detected kubeVersion for helm diff on this release |
| `takeOwnership` | bool | false | Take ownership of existing resources for this release |
| `serverSide` | string | | Controls the helm 4 `--server-side` flag for this release. Must be `"true"`, `"false"`, or `"auto"` (Helm 4 only) |
@@ -490,7 +515,10 @@ See [Advanced Features](advanced-features.md#resource-tracking-with-kubedog) for
|-------|------|---------|-------------|
| `trackMode` | string | `""` | Track mode: `helm`, `helm-legacy`, or `kubedog` |
| `trackTimeout` | int | 300 | Tracking timeout in seconds |
| `trackLogs` | bool | false | Enable real-time log streaming |
| `trackLogs` | bool | false | Print logs from tracked resources during deployment |
| `trackLogsInterval` | duration | `10s` | Interval between log output updates for `trackLogs` or `trackFailedLogs` (e.g. `1s`; minimum `1s`); overrides the default, but an explicitly supplied `--track-logs-interval` takes precedence |
| `trackLogsUntilReady` | bool | false | With `trackLogs: true`, stop routine pod logs once each pod first becomes ready; readiness and failure tracking continue. Job logs are unaffected |
| `trackFailedLogs` | bool | false | Print collected logs only for pods that fail during deployment; overridden by `trackLogs` |
| `trackKinds` | list | | Whitelist of resource kinds to track |
| `skipKinds` | list | | Blacklist of resource kinds to skip |
| `trackResources` | list | | Specific resources to track (objects with `kind`, `name`, `namespace`) |
+28
View File
@@ -222,6 +222,14 @@ environments:
- http://$HOSTNAME/artifactory/example-repo-local/test.tgz@environments/production.secret.yaml
```
Wildcards are supported in the file selector, the same way as for [remote values files](#loading-remote-environment-values-files):
```yaml
environments:
staging:
secrets:
- git::https://{{ env "GITHUB_PAT" }}@github.com/org/repo.git@environments/staging/secrets/*.yaml?ref=main
```
### Loading remote Environment values files
Since Helmfile v0.118.8, you can use `go-getter`-style URLs to refer to remote values files:
@@ -261,6 +269,26 @@ For more information about the supported protocols see: [go-getter Protocol-Spec
This is particularly useful when you co-locate helmfiles within your project repo but want to reuse the definitions in a global repo.
##### Wildcards in remote values files
The file selector (the part after `@`) can be a glob pattern, so a whole directory of values files can be referenced at once, without listing every file individually:
```yaml
environments:
production:
values:
- git::https://github.com/org/repo.git@config/production/*.yaml?ref=main
```
All matches are fetched from a single clone of the repository, then merged in the same sorted order as a local `values:` glob (see [Precedence](#environment-values-precedence)).
Notes and limitations:
- The pattern is matched with the same syntax as local values file globs (`filepath.Match`: `*`, `?`, `[abc]`), against a single path segment — there is no recursive `**`.
- Wildcards require an explicit `@` selector to mark the repository root, as in the example above.
- Wildcards are only supported with getters that download a whole directory, such as `git::`. Plain `https://`/`s3://` references and a non-archive `s3::` reference each fetch a single file and cannot be expanded; use `git::` (or an `s3::` archive URL) instead.
- A raw `?` in the file selector can never work as a wildcard: it starts the URL's query string (e.g. `?ref=main`), so anything after it is parsed as part of the query, not the file selector. A `?` wildcard must be percent-encoded as `%3F` to survive as part of the path, e.g. `@dir/v%3F.yaml?ref=main`.
- This applies to environment `values:`/`secrets:` only. In release-level `values:`/`secrets:`, a wildcard that matches exactly one file also works, but a wildcard matching more than one file still fails with "glob patterns in release values and secrets is not supported yet" — the same restriction that already applies to local glob patterns there.
### Environment values precedence
With the introduction of HCL, a new value precedence was introduced over environment values.
Here is the order of precedence from least to greatest (the last one overrides all others)
+14
View File
@@ -28,3 +28,17 @@ See [Selectors and needs](releases.md#selectors) for detailed examples.
## HCL helmfile-values-file support
HCL language is supported for environment values files (`.hcl` suffix). This was introduced as experimental in PR #1423 and is now a stable feature. See [Environments](environments.md#hcl-specifications) for details.
## otel-tracing
OpenTelemetry tracing support: export a trace of a helmfile run to any OTLP-compatible backend. The current increments emit the command-level root span, state-loading spans (discover/load/render/parse), one span per external process (helm invocations, hooks, plugin execs), per-hook spans, per-release spans for the sync/diff/delete/status/test/prepare paths, and metrics (`helmfile.helm.exec.duration`, `helmfile.release.duration`, `helmfile.release.count`; per-release dimensions opt-in via `HELMFILE_OTEL_METRICS_PER_RELEASE`) (the remaining loops are being added incrementally) — see [the design proposal](https://github.com/helmfile/helmfile/blob/main/docs/proposals/otel-tracing.md).
Unlike the features above, `otel-tracing` is **not** gated by `HELMFILE_EXPERIMENTAL`. It is off by default and enabled explicitly per run:
```bash
helmfile --otel-tracing -e production apply
# or
export HELMFILE_OTEL_TRACING=true
```
All exporter, sampler, and propagator settings come from the standard `OTEL_*` environment variables (e.g. `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_TRACES_EXPORTER`). See [OpenTelemetry Tracing](otel.md) for details.
+21
View File
@@ -51,6 +51,8 @@ To avoid upgrades for each iteration of `helm`, the `helmfile` executable delega
**Patch**: JSON/Strategic-Merge Patch Kubernetes resources before `helm-install`ing, without forking upstream charts (See [#673](https://github.com/roboll/helmfile/pull/673))
**Observability**: Opt-in [OpenTelemetry traces and metrics](otel.md) for CI/CD runs — see where time goes, per release and per helm invocation (experimental)
## Installation
* download one of [releases](https://github.com/helmfile/helmfile/releases)
@@ -213,6 +215,25 @@ The `selector` parameter can be specified multiple times. Each parameter is reso
In addition to user supplied labels, the name, the namespace, and the chart are available to be used as selectors. The chart will just be the chart name excluding the repository (Example `stable/filebeat` would be selected using `--selector chart=filebeat`).
### The `dir` selector
The `dir=` selector key matches releases by the directory of their defining helmfile, relative to the root directory: the directory of the file passed via `-f` (or that directory itself when `-f` points to one), or the current directory when no `-f` is given. Unlike other selectors, `dir=` uses directory-prefix matching: `--selector dir=apps/opencloud` matches both releases defined directly in `apps/opencloud/` and those in any subdirectory below it. Values are normalized (trailing slashes and `./` prefixes are stripped). Absolute paths, `..` segments that escape the root, and the bare `.` are rejected at parse time; omit the selector entirely to match every release.
The `dir` selector has a second, more important effect: **it short-circuits sub-helmfile traversal**. When `dir=` is set, helmfile inspects each `helmfiles:` entry's path *before* loading or templating it, and skips any branch whose location rules out a matching release. For projects that aggregate many services via nested helmfiles (e.g. opendesk's `helmfile_generic.yaml.gotmpl` lists ~13 services), running `helmfile -l dir=apps/<single-service> sync` becomes roughly as fast as targeting that single service directly, without the cost of parsing and templating the other branches.
The label key `dir` is reserved. If `commonLabels`, a release's `labels` or a release template declares a key named `dir`, helmfile logs a warning and the user-defined value is ignored for selector matching (the auto-populated value shadows it, also for releases that have no auto-populated value). **Deprecation:** this will become a hard error in a future release, so rename such labels now.
#### Interactions worth knowing
* Releases loaded from remote (`git::…`) helmfiles do not receive a `dir` label and the optimization does not engage for them; they are always descended into. The same applies to sub-helmfiles located outside the root directory (e.g. `helmfiles: [../shared/helmfile.yaml]`).
* The skip decision only looks at the location of the `helmfiles:` entry itself, so it assumes that nested helmfiles stay at or below their parent's directory. A branch that reaches the target via `..` (e.g. `stacks/prod/helmfile.yaml` listing `../../apps/foo/helmfile.yaml`) is skipped for `-l dir=apps/foo`, and its releases are not found.
* Without `-f`, an auto-discovered `helmfile.d/` is anchored at the current directory, so its releases match `dir=helmfile.d`. With `-f helmfile.d` the anchor is `helmfile.d` itself.
* **A nested `helmfiles:` entry that declares its own `selectors:` block replaces the CLI selectors for its sub-tree** per the existing inheritance model, so a CLI `-l dir=…` neither filters releases in that branch nor skips traversal below it.
* Releases declared in a file loaded via `bases:` are merged into the *including* helmfile, so their `dir` value reflects the including helmfile's location rather than the base file's.
For example, with `apps/web/helmfile.yaml` containing `bases: [../../common/releases.yaml]`, every release declared in `common/releases.yaml` gets `dir=apps/web`, not `dir=common`; `-l dir=common` matches nothing.
* Globs in `helmfiles:` (e.g. `apps/**/helmfile.yaml`) are expanded before the dir-skip check, so a missing-file error in an entry that the dir filter would have skipped still fails the parent load. Use `missingFileHandler: Warn` if this is a problem.
* Cross-branch transitive `needs:` are not auto-resurrected across pruned branches. If a release in `apps/x` depends on one in `apps/y`, run with a `dir=` covering both subtrees.
`commonLabels` can be used when you want to apply the same label to all releases and use [templating](templating.md) based on that.
For instance, you install a number of charts on every customer but need to provide different values file per customer.
+110
View File
@@ -0,0 +1,110 @@
# OpenTelemetry Tracing
Helmfile can export [OpenTelemetry](https://opentelemetry.io/) traces of a run, which is especially useful in CI/CD: see where time is spent, which helm invocations dominate, how label selectors fan out, and what to target for improvement.
Tracing is **experimental** and **off by default**. It currently emits the command-level root span, state-loading spans (discover/load/render/parse), one span per external process (helm invocations, hooks, plugin execs), per-hook spans, and per-release spans for the sync/diff/delete/status/test/prepare paths.
## Enabling
```bash
helmfile --otel-tracing -e production -l tier=backend apply
```
or via environment variable (handy for CI):
```bash
export HELMFILE_OTEL_TRACING=true
helmfile sync
```
`--otel-tracing` overrides `HELMFILE_OTEL_TRACING` when specified.
## Configuration
Everything beyond the on/off switch uses the standard [OpenTelemetry environment variables](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/); helmfile defines no telemetry-specific variables of its own.
| Variable | Default | Notes |
|---|---|---|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | OTLP endpoint; use `http://host:4317` for gRPC |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` | or `grpc` |
| `OTEL_EXPORTER_OTLP_HEADERS` | — | e.g. `authorization=Bearer <token>` |
| `OTEL_TRACES_EXPORTER` | `otlp` | `otlp` \| `console` \| `none` |
| `OTEL_METRICS_EXPORTER` | `otlp` | `otlp` \| `console` \| `prometheus` \| `none` — see [Metrics](#metrics) |
| `OTEL_TRACES_SAMPLER` (+ `OTEL_TRACES_SAMPLER_ARG`) | `parentbased_always_on` | |
| `OTEL_SERVICE_NAME` | `helmfile` | |
| `OTEL_RESOURCE_ATTRIBUTES` | — | e.g. `cicd.pipeline=deploy,cicd.run_id=4821` |
| `OTEL_PROPAGATORS` | `tracecontext,baggage` | |
| `OTEL_SDK_DISABLED` | `false` | standard kill switch |
HTTP proxies (`HTTPS_PROXY`/`HTTP_PROXY`) are honored automatically by the Go runtime.
## Quick start without a collector
To inspect what would be exported, print spans as JSON on stdout:
```bash
OTEL_TRACES_EXPORTER=console helmfile --otel-tracing -l name=myrelease template
```
## Quick start with Jaeger
```bash
docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
helmfile --otel-tracing -e production apply
# Open http://localhost:16686 and pick service "helmfile"
```
## What gets traced
The root span is named after the command (`helmfile apply`, `helmfile sync`, ...) and carries `helmfile.command`, `helmfile.file`, `helmfile.environment`, and `helmfile.selectors` attributes, plus `helmfile.exit_code` and the error (if any) at the end.
Every external process helmfile starts — each helm invocation, hook command, and plugin exec — gets its own span nested under the command span: `helm.exec` (with `helm.subcommand`) for helm binaries, `os.exec` otherwise, both carrying `exec.command`, redacted `exec.args`, and `exec.exit_code` on failure.
State loading is traced too: `helmfile.discover_states`, one `helmfile.load` per state file (including nested helmfiles), with `helmfile.render` and `helmfile.parse` children — rendering is frequently the hidden time sink. Each hook execution produces a `helmfile.hook` span (with `hook.event` and `hook.name`) that its subprocess span nests under — and release-scoped hooks (presync/postsync & co. within sync/diff/delete) nest under their release's span, keeping full release attribution.
Per-release spans (`helmfile.release.sync` / `.diff` / `.delete` / `.status` / `.test` / `.prepare`) carry the release name, namespace, chart, and labels, and nest under the state-file load span — with the release's helm subprocesses (upgrade, diff, delete, status, test) nested under the release span. The remaining loops (flag preparation, template/lint/unittest) are being added incrementally; see [the design proposal](proposals/otel-tracing.md) for the full taxonomy.
Secret-bearing command arguments (`--set`, `--set-file`, `--username`, `--password`, ...) are never recorded in span attributes — they are masked before export.
## Metrics
Tracing and metrics share the same switch and resource; metrics are exported through the standard metrics environment variables:
| Variable | Default | Notes |
|---|---|---|
| `OTEL_METRICS_EXPORTER` | `otlp` | `otlp` \| `console` \| `prometheus` \| `none` |
| `OTEL_METRIC_EXPORT_INTERVAL` | `60000` (ms) | periodic export interval; the final flush happens at exit |
Three instruments are emitted:
| Metric | Type | Attributes |
|---|---|---|
| `helmfile.helm.exec.duration` | histogram (seconds, buckets tuned for 5ms–600s helm invocations) | `subcommand`, `success` |
| `helmfile.release.duration` | histogram (seconds, same buckets) | `verb`, `result` — plus `helmfile.release` and `helmfile.namespace` when per-release metrics are enabled (below) |
| `helmfile.release.count` | counter (unit `{release}`) | `verb` (sync/diff/delete/status/test/prepare), `result` (success/error) |
### Per-release metrics (opt-in, high cardinality)
By default metric dimensions are bounded enumerations — release names never appear, so time-series count stays constant regardless of fleet size. If you need per-release durations on dashboards, opt in:
```bash
HELMFILE_OTEL_METRICS_PER_RELEASE=true helmfile --otel-tracing sync
```
This adds `helmfile.release` and `helmfile.namespace` to `helmfile.release.duration`. Time-series count then scales with your release fleet: well-suited to bounded CI runs, but long-lived centralized collection needs a backend capacity/TTL story. (Per-release timing is always available in traces, without this flag.)
Both dimensions are bounded enumerations — never release names — so metric cardinality stays constant regardless of fleet size. The instrumentation scope (`helmfile`) carries the helmfile version.
Inspect them without a collector with `OTEL_METRICS_EXPORTER=console`.
## CI trace correlation
If your CI system injects a W3C `TRACEPARENT` environment variable into jobs, helmfile joins that trace automatically, so helmfile spans appear inside your pipeline's trace.
## Troubleshooting
- **No spans arrive**: check `OTEL_EXPORTER_OTLP_ENDPOINT`/protocol match your collector (4318 for `http/protobuf`, 4317 for `grpc`); verify with `OTEL_TRACES_EXPORTER=console`.
- **Tracing breaks nothing**: exporter/sampler misconfiguration and export failures never fail a helmfile run; at worst you get no spans.
- **Still stuck?** Set `OTEL_SDK_DISABLED=false` explicitly and rerun with `--log-level debug`.
+560
View File
@@ -0,0 +1,560 @@
# Proposal: OpenTelemetry Tracing Support
- **Issue**: [#2767 — feat-request: OpenTelemetry (tracing) support](https://github.com/helmfile/helmfile/issues/2767)
- **Status**: Draft
- **Origin discussion**: [#2758](https://github.com/helmfile/helmfile/discussions/2758)
## 1. Summary
Add opt-in OpenTelemetry (OTel) distributed tracing to helmfile. When enabled, a helmfile
run produces a trace whose spans cover the full execution timeline — state-file discovery,
template rendering, per-release operations, hooks, and every helm subprocess helmfile itself
starts — exported via OTLP to any OTel-compatible backend (Jaeger, Tempo, Zipkin,
Honeycomb, Datadog, Grafana Cloud, cloud-vendor collectors, ...).
This directly serves the motivating use case from CI/CD: *see where time is spent, what runs
most often (label selectors), and what to target for improvement* — the same capability
Terragrunt ships today ([Terragrunt OTel docs](https://docs.terragrunt.com/troubleshooting/open-telemetry/)).
When disabled (the default), behavior and performance are identical to today: a no-op tracer
provider, no goroutines, no network, no exported spans (explicit guarantees in §7).
### Example resulting trace
```
helmfile sync file=helmfile.yaml env=production (root, ~2m)
├─ helmfile.discover_states (~5ms)
├─ helmfile.load file=helmfile.yaml (~800ms)
│ ├─ helmfile.render pass=values (~400ms)
│ └─ helmfile.parse (~50ms)
├─ helmfile.repos.update (~4s)
│ └─ helm.exec subcommand="repo update" (~4s)
├─ helmfile.release.prepare release=gateway chart=gateway (~9s)
│ └─ helm.exec subcommand="dependency build" (~8s)
├─ helmfile.release.sync release=envoy ns=ingress (~21s)
│ ├─ helmfile.hook event=presync (~2s)
│ │ └─ os.exec command="./migrate.sh" (~2s)
│ ├─ helm.exec subcommand="upgrade --install envoy ..." (~18s)
│ └─ helmfile.hook event=postsync (~1s)
├─ helmfile.release.sync release=api ns=apps (~35s)
└─ helmfile.wait release=api (kubedog) (~30s)
```
## 2. Goals and non-goals
### Goals
1. **Opt-in, zero-cost-when-off**: tracing disabled by default; no measurable overhead or
behavior change when not requested.
2. **Standard OTLP export** configured through the [OTel environment-variable
specification](https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/)
— no helmfile-specific duplicate knobs for endpoints/protocols/headers/sampling.
3. **Meaningful hierarchy**: spans nest (command → state file → release → hook/subprocess)
so the critical path of a sync/apply is visible at a glance — *including* the kubedog and
hook paths, which today run on detached contexts (§4.3).
4. **Safe by default**: span attributes never contain secret-bearing values
(`--set` values, registry credentials, secret refs).
5. **CI-friendly**: W3C `tracecontext` propagation so a CI system that injects
`TRACEPARENT` gets spans correlated into its own trace.
### Non-goals (for the initial implementation)
- OTel **metrics** and **log export** (the SDK setup leaves room; see §12 for follow-ups).
- Tracing *inside* the helm binary or cluster-side (helm/chart hooks are separate processes;
context injection into them is an open question, §14).
- Fixing the pre-existing cancellation gaps discovered during design (kubedog path rooted at
`context.Background()`, hooks at `context.TODO()` — §4.3). This proposal bridges them for
*trace context only*, with byte-identical cancellation semantics; semantic fixes are
reported separately.
- Any helmfile-config-file surface (`helmfile.yaml`) for telemetry — CLI flags + env vars only,
matching how observability tooling is usually injected by the platform, not the state author.
## 3. User-facing design
### 3.1 Activation
| Mechanism | Default | Description |
|---|---|---|
| `--otel-tracing` (persistent flag) | `false` | Enable tracing for this run |
| `HELMFILE_OTEL_TRACING=true` (env) | `false` | Same, via environment (CI-friendly) |
The feature ships as **experimental** initially and is listed in
`docs/experimental-features.md` (see §11 Rollout). We do *not* require
`HELMFILE_EXPERIMENTAL=otel-tracing`: tracing is purely additive, invisible when off, and
gating it twice would complicate CI adoption. The experimental label sets stability
expectations only. (Note: this differs from the current entries in that document, which are
gated by `HELMFILE_EXPERIMENTAL`; the entry will say so explicitly.)
### 3.2 Exporter & SDK configuration — standard OTel env vars only
When `--otel-tracing` is set, helmfile initializes the OTel SDK honoring the standard
environment variables. Exporter selection is delegated to
`go.opentelemetry.io/contrib/exporters/autoexport` (already in the module graph, §8) rather
than hand-rolled, so helmfile maintains no exporter-construction code of its own:
| Variable | Default in helmfile | Read by | Notes |
|---|---|---|---|
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | otlp client (SDK) | default follows the protocol: 4318 with `http/protobuf` (below), 4317 with `grpc` |
| `OTEL_EXPORTER_OTLP_PROTOCOL` / `..._TRACES_PROTOCOL` | `http/protobuf` | autoexport | `grpc`, `http/protobuf` (traces-specific var wins; `http/json` is not supported by autoexport traces in v0.67.0) |
| `OTEL_EXPORTER_OTLP_HEADERS` / `..._TRACES_HEADERS` | — | otlp client (SDK) | e.g. collector auth tokens |
| `OTEL_EXPORTER_OTLP_TIMEOUT` / `..._TRACES_TIMEOUT` | `10s` | otlp client (SDK) | per-export timeout |
| `OTEL_EXPORTER_OTLP_INSECURE` | per endpoint scheme | otlp client (SDK) | plaintext export for local collectors |
| `OTEL_TRACES_EXPORTER` | `otlp` | autoexport | `otlp` \| `console` \| `none` (verified against autoexport v0.67.0; more values possible via `RegisterSpanExporter`) |
| `OTEL_TRACES_SAMPLER` (+`..._ARG`) | `parentbased_always_on` | our wrapper | standard samplers (the Go SDK core does not parse this env itself) |
| `OTEL_SERVICE_NAME` | `helmfile` | SDK resource | service identity (`resource` `WithFromEnv`; verified in sdk v1.44.0 `resource/env.go`) |
| `OTEL_RESOURCE_ATTRIBUTES` | — | SDK resource | e.g. `deployment.environment=ci,cicd.pipeline=release` (same verified reader) |
| `OTEL_PROPAGATORS` | `tracecontext,baggage` | our wrapper | extract parent from CI-injected `TRACEPARENT` (SDK core does not parse this env either) |
| `OTEL_METRICS_EXPORTER` | `otlp` | autoexport (metrics) | `otlp` \| `console` \| `prometheus` \| `none` — delivered in PR 4 |
| `OTEL_METRIC_EXPORT_INTERVAL` | `60000` (ms) | SDK metric reader | periodic export interval; final flush happens at exit |
| `OTEL_SDK_DISABLED` | `false` | our wrapper | standard kill switch (the Go SDK does not read this one itself) |
`console` (JSON spans on **stdout**, via `stdouttrace`) exists for local debugging without a
collector — note stdout, so it does not interleave with helmfile's stderr logs;
`none` produces no export (used by tests and for pure-propagation setups).
HTTP proxies (`HTTPS_PROXY`/`HTTP_PROXY`) are honored automatically by the Go HTTP/gRPC
stacks — no helmfile-specific proxy configuration.
Minimal CI example:
```bash
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel.example.com"
export OTEL_EXPORTER_OTLP_HEADERS="authorization=Bearer ${OTEL_TOKEN}"
export OTEL_SERVICE_NAME="helmfile-ci"
export OTEL_RESOURCE_ATTRIBUTES="cicd.pipeline=deploy,cicd.run_id=4821"
helmfile --otel-tracing -e production -l tier=backend apply
```
## 4. Architecture
### 4.1 New package `pkg/telemetry`
Single, dependency-light façade over the OTel SDK. No other package imports OTel SDK
exporters directly.
```
pkg/telemetry/
├── telemetry.go // Setup/Shutdown lifecycle, CommandContext(), Tracer(name)
├── exporter.go // thin autoexport wiring + propagators (no exporter construction)
└── telemetry_test.go
```
Public surface sketch:
```go
package telemetry
type Options struct {
Enabled bool
Version string // helmfile version, recorded as service.version
Logger *zap.SugaredLogger // for one-line diagnostics, never span data
}
// Setup initializes the global provider. Idempotent; a no-op when
// opts.Enabled is false. Exporter/sampler misconfiguration and
// OTEL_SDK_DISABLED=true degrade to disabled with a warning — telemetry
// problems never fail a helmfile run.
func Setup(ctx context.Context, opts Options)
// StartCommandSpan starts the root span for one command invocation, joins a
// remote parent from TRACEPARENT/TRACESTATE/BAGGAGE env vars, and makes its
// context the one returned by CommandContext. No-op when disabled.
func StartCommandSpan(command string, attrs ...attribute.KeyValue)
// CommandContext returns the root command span's context, or
// context.Background() when telemetry is disabled. Single source of truth
// for deriving App.ctx.
func CommandContext() context.Context
// Tracer returns a tracer for the given instrumentation scope. Never nil:
// returns the OTel no-op tracer when telemetry is disabled, so callers need
// no `if enabled` branches. Sanctioned scopes: ScopeHelmfile, ScopeHelm.
func Tracer(name string) trace.Tracer
// Shutdown ends the command span (recording runErr and exitCode), flushes
// buffered spans bounded by ctx, and reverts to disabled. Idempotent and
// nil-safe (safe before Setup, twice, or on a signal that raced Setup).
// ShutdownTimeout is the recommended flush bound.
func Shutdown(ctx context.Context, runErr error, exitCode int) error
```
Design constraints for maintainability:
- **No domain knowledge in this package.** Span-attribute redaction rules live with the
existing redaction code in `pkg/helmexec` (§6); telemetry only consumes the result.
A telemetry package that knows about `--set` semantics would be a layering violation.
- Sanctioned instrumentation scopes are only `"helmfile"` (app/state layer) and `"helm"`
(helmexec layer), documented in `telemetry.go`.
### 4.2 Lifecycle
```
main.go cmd/root.go
────── ───────────
rootCmd.Execute() ─────────────► PersistentPreRunE:
logger setup (existing)
telemetry.Setup(ctx, opts) ← provider + exporters
start root span "helmfile <sub>"
RunE / subcommand execution … (child spans attach via context)
errChan <- Execute()
┌────────────────────────────────┐
shutdown: │ end root span (status=error on │
rootSpan.End() │ failure, exit code attr) │
provider.Shutdown(ctx 5s) ◄─────┘ │
```
- `PersistentPreRunE` (`cmd/root.go`) is the natural init point: it already centralizes
logger construction from `GlobalOptions`, and receives the `*cobra.Command`, so
`c.Name()` gives the root span name (`helmfile sync`, ...).
- **`App.ctx` derivation requires no signature changes.** `app.New(conf)` is called from
every subcommand (`cmd/*.go`, 22 call sites) and currently roots its context at
`context.Background()` (`pkg/app/app.go`). Instead of threading a parameter through all
callers, `app.New` replaces that single `context.Background()` with
`telemetry.CommandContext()` — Background-identical when tracing is off, span-rooted
when on. Cancellation is unaffected (`context.WithCancel` on either parent behaves the
same for SIGINT handling in `main.go`).
- **Shutdown must run even on failure and on signals.** The end of
`rootCmd.Execute()` in `main.go` (both the `errChan` and the `SIGINT`/`SIGTERM` paths;
on the error path *before* `errors.HandleExitCoder`, which terminates via `OsExiter`,
and on the signal path after `app.CleanWaitGroup.Wait()` and *before*
`os.Exit(130/143)`) calls the returned shutdown func with a 5s-timeout context so buffered
spans are flushed. The stored shutdown must be nil-safe — a signal arriving before
`PersistentPreRunE` completed (i.e. before `Setup`) must be a no-op, never a panic. This
is the one behavior that is easy to get wrong and is explicitly tested (§10).
- Setup failures (e.g. unusable exporter configuration) are logged as a warning and
**do not fail the run** — telemetry must never break deployments. Export errors after
startup surface only through OTel's own error handler, wired to the zap logger.
### 4.3 Verified context-reality map (as of this writing)
All claims below were checked against the code; line numbers are anchors for reviewers:
| Path | Today | Consequence for tracing |
|---|---|---|
| cmd → app | `app.New` roots at `context.Background()`; `ctx, Cancel = WithCancel(ctx)` (`pkg/app/app.go`, `New`) | span must be injected here (§4.2) |
| app → all helm execs | `getHelm()` constructs the `ShellRunner` with `Ctx: a.ctx` (`pkg/app/app.go:1008`); the resulting `execer` is **cached per (helm binary, kube-context)** in `a.helms` and shared by all releases and workers (`pkg/app/app.go:982–1021`) | once `App.ctx` is span-rooted, every non-kubedog helm call nests automatically, with **zero changes** to `getHelm`. The shared-instance cache is also why per-release contexts must ride per-call parameters, never mutation of the shared execer |
| kubedog path (sync with tracking) | originally `startBackgroundKubedogTracking(gocontext.Background(), …)` (`pkg/state/state.go`), with `bufferHelmOutput` deriving `releaseCtx := context.WithCancel(ctx)` and swapping it in via `execer.WithContext(releaseCtx)` (`pkg/state/helmx.go`). **Since #2791** the three call sites pass `st.releaseCancelContext()` — the app cancel context injected via `HelmState.SetCancelContext` — so SIGINT/SIGTERM reaches these helm subprocesses too (#2770) | trace context on this path is the same command span as everywhere else: the §4.4 bridge kept spans attached while cancellation was detached, and #2791 subsequently re-attached cancellation |
| hooks | both `event.Bus` constructions (`triggerGlobalReleaseEvent`, `triggerReleaseEvent`, `pkg/state/state.go:3666, 3703`) duplicate the same literal and pass **no** `Runner`, so the default kicks in: `ShellRunner{Dir: bus.BasePath, Logger: bus.Logger, Ctx: goContext.TODO()}` with an inline comment acknowledging it should be `app.Ctx` (`pkg/event/bus.go:61–71`) | hook execs are detached; spans would be orphans. Bridged in §4.4 |
| non-kubedog release workers | release loops (`SyncReleases` etc., `pkg/state/state.go:1212 ff.`) call the shared `helmexec.Interface` with a `HelmContext` (`pkg/helmexec/context.go`) that carries **no go-context** | per-release spans need the §4.4 mechanism |
| subprocess funnel | exactly three `ShellRunner` construction sites exist (verified exhaustive): `pkg/app/app.go:129` (`Init`, `Ctx: a.ctx`), `pkg/app/app.go:1006` (`getHelm`, `Ctx: a.ctx`), and the hooks default (`pkg/event/bus.go:62`, `Ctx: TODO` — §4.4 bridge). Every external process helmfile itself starts goes through `Execute`/`ExecuteStdIn` (`pkg/helmexec/runner.go`); helm commands additionally funnel through `execer.exec()` (`pkg/helmexec/exec.go:1207`). Exception: kustomize executes inside the chartify library, outside this funnel (§12) | one instrumentation point covers everything except chartify-internal execs; spans nest wherever the runner's `Ctx` carries a span |
Two pre-existing gaps surfaced by this analysis — kubedog tracking not being cancellable via
`App.ctx`, and hooks likewise — are **out of scope** for this proposal beyond trace-context
bridging (§4.4), because fixing their *cancellation* semantics would be a behavior change.
They should be reported as separate issues. (Update: the kubedog gap was fixed by
#2791 via `HelmState.SetCancelContext`; the hooks gap remains open as #2771.)
### 4.4 Context plan
**Phase 1 (no state-package changes):**
1. Root span ctx via `telemetry.CommandContext()` → `app.New` (§4.2). Every `a.ctx`-rooted
exec nests. Discover/load/render spans in `pkg/app` also need no signature changes:
the `helmfile.load` span starts inside `loadDesiredStateFromYamlWithBaseDir`
(`pkg/app/app.go:932`) with `a.ctx` as parent — its two call sites (app.go:1045 and
the nested-helmfile path at app.go:1280) are thereby both covered — and `helmfile.render`
children parent through an unexported `ctx` field on the `desiredStateLoader` struct
(`pkg/app/desired_state_file_loader.go:29`), set once at its single construction site
(`pkg/app/app.go:938`). Zero method-signature changes, zero exported API.
2. Runner-level spans in `ShellRunner.Execute`/`ExecuteStdIn` nest for all non-kubedog,
non-hook execs automatically.
3. **Orphan-bridge for kubedog and hooks, with identical cancellation semantics:**
- kubedog: originally pass `context.WithoutCancel(telemetry.CommandContext())`
instead of `gocontext.Background()`. `WithoutCancel` preserved values (the span)
while dropping cancellation — and `Background` never carried cancellation anyway, so
SIGINT/timeout behavior stayed **bit-for-bit unchanged**; only trace context was
added. (Phase 1 used the root span from `telemetry.CommandContext()`, which required
no new plumbing in `pkg/state`; phase 2 re-parented under the per-release
`st.traceCtx`.) Superseded by #2791: the three kubedog call sites now pass
`st.releaseCancelContext()` — same command-span trace context, plus app
cancellation, fixing #2770.
- `pkg/event/bus.go`: add an optional `Ctx context.Context` field to `Bus`; the default
runner construction uses `bus.Ctx` when set, `TODO` when nil (so behavior is unchanged
for any nil-Ctx caller). The two construction sites in `pkg/state/state.go:3666, 3703`
set it from the same span-rooted, cancel-stripped context. `Dir`/`Logger` wiring of
the default runner is untouched.
**Phase 2 (per-release spans, still zero changes to `helmexec.Interface` signatures):**
- Start `helmfile.release.<verb>` spans in the seven release-worker loops — six
`scatterGather` sites in `pkg/state/state.go` (`prepareSyncReleases`:894,
`DeleteReleasesForSync`:1135, `SyncReleases`:1241, `PrepareCharts`:2328,
`prepareDiffReleases`:3068, `DiffReleases`:3266) plus `iterateOnReleases`
(`pkg/state/state_run.go:59`, the shared loop behind test/lint/unittest-style
iteration) — all following the same `scatterGather` shape. (An eighth `scatterGather`
sites, `scatterGatherEnvSecretFiles` at `pkg/state/create.go:486`, decrypts environment
secrets rather than processing releases; an optional `helmfile.env_secrets` span there
is a follow-up in the spirit of §4.5 item 6.)
- Release spans are rooted via a new unexported `traceCtx` field on `HelmState`, exposed
through a purely additive exported setter called by `pkg/app` right after state
creation. (Why a setter and not a constructor parameter: `st.logger` is injected through
`state.NewCreator` — a 9-positional-parameter exported function,
`pkg/state/create.go:80` — so threading a context through it would churn a public
signature used by the app loader; an additive setter touches no existing signature and
defaults to nil, i.e. current behavior.)
- Carry the span context per call by adding an optional `Ctx context.Context` field to
`HelmContext` (`pkg/helmexec/context.go`), stamped **inside** `createHelmContext`
(`pkg/state/state.go:3168`) so all eight call sites (state.go:1007, 1026, 1147, 1261,
3034, 3184, 3358, 3374) inherit it without per-call-site edits.
- Funnel it inside `helmexec`: the `execer` methods that take a `HelmContext` pass its
`Ctx` to `execWithContext`, which **injects the release span into the runner's own
context** (`trace.ContextWithSpan`) rather than replacing it — cancellation authority
stays with the runner (including the kubedog safety valve installed via
`WithContext`), which a naive context replacement would have overridden. A nil `Ctx`
behaves exactly like the plain funnel. Helm-vs-other classification comes from an
explicit marker stamped in the execer funnels (`withRunnerCtx`/`markHelmRunner`,
value and pointer runner forms), not from the executable basename — so wrapper
`--helm-binary` names classify correctly. The `exectest.Helm` fake is unaffected: app
tests pre-seed `App.helms` with it (`pkg/app/app_template_test.go:115–116`), so it
replaces the whole `helmexec.Interface` and bypasses `execer` internals entirely.
*Why per-call threading rather than the existing `WithContext` clone: `WithContext`
(`pkg/helmexec/exec.go:251`) is suited to whole-execution substitution (kubedog path),
but release workers share one cached execer across concurrent workers (§4.3), so a
per-release context must travel with the per-release `HelmContext` parameter.*
### 4.5 Instrumentation points (in priority order)
1. **`helmexec.ShellRunner.Execute` / `ExecuteStdIn`** (`pkg/helmexec/runner.go`) — the single
choke point for *every* external process started by helmfile itself: helm invocations,
hooks, and helmfile plugin execs. One span per subprocess: name `helm.exec` when `cmd`
is the helm binary, else `os.exec`. The one exception is kustomize, which runs inside
the `github.com/helmfile/chartify` library (see §5/§12). This instrumentation alone
delivers most of the requested value (where does time go).
2. **Root command span** — `cmd/root.go` (see §4.2).
3. **State loading** — span `helmfile.load` started inside
`loadDesiredStateFromYamlWithBaseDir` (`pkg/app/app.go:932`, covering both callers
incl. nested helmfiles), with `helmfile.render`/`helmfile.parse` children in
`two_pass_renderer.go` parented via the loader-struct `ctx` field (§4.4 step 1) —
rendering is frequently the hidden time sink.
4. **Per-release operations** — release loops in `pkg/state/state.go` (phase 2, §4.4).
5. **Hooks** — `pkg/event/bus.go` `Trigger` (`pkg/event/bus.go:56`): span `helmfile.hook`
with `hook.event`, `hook.name` attributes; naturally parents the `os.exec` span of the
hook command once the §4.4 bridge is in place.
6. **(Phase 2)** kubedog wait spans, vals/remote secret resolution, `helm repo` retry loops.
## 5. Span taxonomy
| Span name | Attributes (beyond standard `otel.*`) | Notes |
|---|---|---|
| `helmfile <subcommand>` (root) | `helmfile.command`, `helmfile.file`, `helmfile.environment`, `helmfile.selectors`, `helmfile.exit_code` | `error` status + recorded error on failure. Service identity (`service.name`, `service.version`) lives on the OTel resource, not on spans |
| `helmfile.discover_states` | `helmfile.path` | `findDesiredStateFiles` (`pkg/app/app.go:1642`) |
| `helmfile.load` | `helmfile.state_file` | one per file in `helmfile.d`, nested helmfiles |
| `helmfile.render` | `helmfile.state_file`, `helmfile.pass`=`values`\|`main` | two-pass rendering (`pkg/app/two_pass_renderer.go`) |
| `helmfile.repos.update` | — | wraps `helm repo update` |
| `helmfile.release.prepare` | `helmfile.release`, `helmfile.namespace`, `helmfile.chart`, `helmfile.chart_version` | chart pull/build/registry login |
| `helmfile.release.sync` / `.diff` / `.template` / `.delete` / `.test` / `.lint` / `.unittest` | same as above + `helmfile.labels` | one per selected release (phase 2) |
| `helmfile.hook` | `hook.event` (presync/…), `hook.name` | |
| `helm.exec` / `os.exec` | `exec.command`, `exec.args` (strict-redacted, plus URL userinfo/query masking via `RedactedRef`), `exec.redacted`, `exec.exit_code` (on failure), `helm.subcommand` (helm only) | one per external process; helm classification is marker-based (wrapper binaries included); release identity comes from the parent release span. Hooks, kustomize (invisible, §12), plugin execs land here too |
Attribute values are strings/ints only; no structured payloads, no output capture in spans
(output already flows through logs).
## 6. Security and redaction
Traces leave the machine they run on. Ground rules, checked against what exists today:
1. **The existing redaction is not sufficient on its own.** The exit-error path
(`pkg/helmexec/exit_error.go:8–20`) redacts only the argument *following* a flag whose
name starts with `--set` (two-argument form). It does not cover the single-argument
`--set=key=value` form, nor credential-bearing flags such as `--username` or
`--password`. (Registry passwords themselves already travel via stdin —
`--password-stdin`, `pkg/helmexec/exec.go` `RegistryLogin` — but usernames appear in
args.) Therefore:
2. **One shared redaction implementation, two profiles — spans get the strict superset
without touching existing error output.** Extract the exit-error redaction into an
exported helper in `pkg/helmexec` (keeping it in the domain that owns flag semantics)
with two profiles:
- `legacy`: byte-identical to today's exit-error behavior — the current goldens in
`pkg/helmexec/exit_error_test.go` (which assert the exact `--set` / `*** STRIP ***`
shape) keep passing unchanged. The exit-error path switches to this profile, so
error messages are unchanged.
- `strict`: the superset required for spans — all `--set*` forms including
`--set=k=v`, plus `--username`, `--password`, `--key-file`,
`--kube-token`, ...; positional arguments are additionally passed through
`helmexec.RedactedRef`, which masks go-getter forced forms (`git::`,
`s3::`), whole URL userinfo, and credential-bearing query parameters,
failing closed for malformed references. The previous token is always
read from the original input so adjacent secret flags cannot leak.
Both profiles are the same code path, so the span view is guaranteed at least as
redacted as the error view. *Unifying* the two profiles (i.e. tightening exit-error
messages too) would change observable output and is deliberately deferred to a
separate follow-up PR with its own test updates — this proposal changes no existing
message content.
3. **Never record**: `vals://`-resolved values, environment variables, exporter headers.
`OTEL_EXPORTER_OTLP_HEADERS` is the only place collector credentials live; helmfile
never logs it.
4. Chart/repo URLs go through the existing `redactedURL` (`pkg/helmexec/exec.go:184`) —
credentials embedded in URLs are masked.
5. Release *names*, namespaces, chart names, and label selectors are assumed non-secret
(consistent with existing helmfile log output).
6. Note for reviewers: `execer.exec` today logs the *full unredacted* command line at Debug
level (`pkg/helmexec/exec.go:1218`). Span attributes deliberately do **not** mirror that
log line; §10 pins this with a redaction test.
## 7. Performance and zero-impact guarantees
**When disabled (the default):**
- `telemetry.Tracer` returns the OTel no-op tracer. No-op span start/end is a few ns and
allocation-free; provider setup, exporters, and the batch worker goroutine never start.
- `telemetry.CommandContext()` returns `context.Background()`; `app.New` behaves exactly as
today. No flag checks appear in hot loops.
**When enabled:**
- Standard SDK BatchSpanProcessor (5s interval / 512-span batches); metrics
use a periodic reader (default 60s, `OTEL_METRIC_EXPORT_INTERVAL`) with a
final flush at exit. Three instruments exist — `helmfile.helm.exec.duration`,
`helmfile.release.duration`, and `helmfile.release.count` — so metric
cardinality stays tiny by default (bounded by subcommands and verbs, never
by release names); `HELMFILE_OTEL_METRICS_PER_RELEASE` opts into
name/namespace dimensions for bounded CI runs. A
`sync --concurrency=16` run produces at most one span per helm invocation plus one per
release — hundreds, not tens of thousands. Export happens off the critical path; the
only synchronous cost is the ≤5s shutdown flush, paid only when tracing is on.
- SDK spans and exporters are goroutine-safe; helmfile's parallel release workers need no
extra locking.
**Functional-impact checklist (the review criteria for every PR in §11):**
1. No `helmexec.Interface` signature changes in any phase; `getHelm()` unchanged.
2. `app.New` changes one line (`Background()` → `telemetry.CommandContext()`), no call-site
churn; cancellation semantics identical.
3. Kubedog and hook bridging uses `context.WithoutCancel`, which drops cancellation and
keeps values — the swapped-out parents (`Background`/`TODO`) never propagated
cancellation either, so SIGINT/timeout behavior is unchanged. (Superseded for kubedog
by #2791, which re-roots tracking under the app cancel context; hooks stay bridged.)
4. Telemetry setup or export failures never fail or slow the run (warning log only, export
off the critical path).
5. All pre-existing behavior, including the known cancellation gaps of §4.3 and the exact
content of exit-error messages (legacy redaction profile, §6.2), is preserved
bit-for-bit; gap fixes and redaction unification are out of scope and filed separately.
## 8. Dependency impact
The OTel libraries are **already in the module graph as indirect dependencies**, required
transitively by existing direct dependencies (`helm.sh/helm/v4` v4.2.4 requires
`go.opentelemetry.io/otel` v1.44.0; helm v3 and `helmfile/vals` also carry otel modules).
Every module this proposal would import — `otel`, `otel/trace`, `otel/sdk`,
`otel/exporters/otlp/otlptrace/otlptracegrpc`, `.../otlptracehttp`,
`otel/exporters/stdout/stdouttrace`, and `contrib/exporters/autoexport` v0.67.0 — is
already pinned in `go.sum` (verified), so promoting them to direct requires brings
**zero new modules to download**. No conflict with existing OTel usage in the process:
none of helmfile's direct dependencies registers OTel globals in library code — verified
no `SetTracerProvider` call sites under helm v3/v4 `pkg/`, vals, or chartify (helm's own
OTel wiring, where present, lives in its CLI layer, not the libraries helmfile imports).
Choosing `autoexport` (§3.2) also means
helmfile maintains no exporter-construction code; its `RegisterSpanExporter` hook covers
any future backend (e.g. zipkin) without helmfile changes. Note the resulting defaults:
`http/protobuf` on `localhost:4318` unless the user overrides the protocol/endpoint.
## 9. Code layout of the change
```
cmd/root.go // --otel-tracing flag, Setup call, root span, shutdown handoff
main.go // shutdown on both exit and signal paths
pkg/config/global.go // OtelTracing option (+ accessor on GlobalImpl)
pkg/envvar/const.go // OtelTracing = "HELMFILE_OTEL_TRACING"
pkg/telemetry/… // new package (§4.1)
pkg/app/app.go // app.New: Background() → telemetry.CommandContext(); helmfile.load span; loader ctx wiring
pkg/app/desired_state_file_loader.go // ctx field on the unexported desiredStateLoader struct (render-span parent)
pkg/app/two_pass_renderer.go // helmfile.render/helmfile.parse spans
pkg/helmexec/runner.go // helm.exec / os.exec spans (single choke point)
pkg/helmexec/redact.go // shared args redaction, legacy+strict profiles (extracted from exit_error.go)
pkg/helmexec/exit_error.go // calls shared helper with legacy profile (output byte-identical)
pkg/helmexec/context.go // HelmContext.Ctx field (phase 2)
pkg/helmexec/exec.go // execCtx funnel beside exec/execStdIn (phase 2)
pkg/state/state.go // WithoutCancel bridges (both event.Bus constructions; the kubedog call sites moved to st.releaseCancelContext() in #2791); release spans (phase 2)
pkg/state/helmx.go // (no change — bridge happens at its caller)
pkg/event/bus.go // optional Ctx field consumed by the default runner
docs/experimental-features.md // feature entry → promoted out when stable
docs/otel.md // user guide (config, backends, CI recipes, sample trace)
```
## 10. Testing strategy
1. **Unit (`pkg/telemetry`)**: table-driven tests for enabled/disabled no-op guarantees,
`CommandContext()` identity, default resource attributes (`service.name=helmfile`,
`service.version` from `pkg/app/version`), and propagator extraction of `TRACEPARENT`.
Global state reset via `export_test.go` so tests stay isolated.
2. **Redaction (`pkg/helmexec`)**: table-driven tests for both profiles — `legacy` pinned
byte-identical by the existing `exit_error_test.go` goldens; `strict` covering `--set v`
and `--set=k=v`, `--set-string`/`--set-file`/`--set-json`, `--username`/`--password`,
benign flags untouched. Pins §6.6.
3. **Span-hierarchy golden tests**: drive `App` with the existing fake helm
(`pkg/exectest/helm.go`, pre-seeded into `App.helms` the way current app tests do) and
an in-memory exporter; assert the span tree (names, parent
links, order) for `template`/`sync` over a small fixture — catches context-plumbing
regressions. Includes an **orphan-span regression test** for the kubedog and hook paths
(§4.3): every exported span must have the root span as an ancestor.
4. **OTLP end-to-end**: an `httptest` server speaking OTLP/HTTP+protobuf, pointed at by
`OTEL_EXPORTER_OTLP_ENDPOINT`; decode exported payloads (`go.opentelemetry.io/proto/otlp`,
already in the graph) and assert count/attributes. Runs in unit-test context — no
external collector needed in CI.
5. **Lifecycle tests**: shutdown flushes on command failure and on SIGINT, so spans never
vanish on failing deploys — the case users care about most. (Requires extracting
`main.go`'s signal/select/exit logic into a small pure function; the extraction itself
is behavior-preserving and covered by the same tests.) Also covers the nil-shutdown
race noted in §4.2.
6. **Regression suite**: existing `make test` must pass unchanged with tracing compiled in
but disabled. Note the existing `pkg/app` tests construct `&App{...}` literals directly
and never call `app.New`, so they do *not* guard the §4.2 one-liner — PR 1 adds a
targeted test asserting `app.New` roots cancellation (and the span) exactly as before.
## 11. Documentation & rollout
1. `docs/otel.md` — user guide: enabling, env vars, collector recipes (Jaeger all-in-one,
Grafana Tempo, vendor SaaS), CI correlation via `TRACEPARENT`, sample trace reading.
Linked from `docs/index.md`.
2. Feature listed under **experimental** in `docs/experimental-features.md` for one or two
minor releases (feedback on span taxonomy is the main thing that may change), then
promoted to stable with a CHANGELOG entry.
3. PR sequence (each independently shippable and revertible, each measured against the
§7 functional-impact checklist):
- **PR 1**: `pkg/telemetry` + flag/env + root span + lifecycle + `app.New` one-liner +
docs + tests (§4.1–4.2).
- **PR 2**: `ShellRunner` instrumentation + shared redaction extraction/extension (§4.4
phase-1 steps 2–3, §6) — the core value.
- **PR 3**: load/render spans + hook bridging + per-release spans via
`HelmContext.Ctx`/`execCtx` (§4.4 phase 2).
- **PR 4 (post-stabilization)**: metrics (e.g. `helmfile.helm.exec.duration` histogram,
`helmfile.release.count` by result), kubedog/vals spans.
## 12. Future work (explicitly out of scope for v1)
- ~~Metrics pipeline on the same provider (`otel/sdk/metric` with the same env-var config)~~ — delivered: `helmfile.helm.exec.duration`, `helmfile.release.count`, and `helmfile.release.duration` via `autoexport.NewMetricReader` (`OTEL_METRICS_EXPORTER`); per-release name/namespace attributes on the duration histogram are opt-in via `HELMFILE_OTEL_METRICS_PER_RELEASE` (bounded dimensions by default).
- `TRACEPARENT` injection into helm subprocess env so chart-test hooks / plugins can extend
the helmfile trace.
- Subprocesses started inside `github.com/helmfile/chartify` (kustomize, and any helm calls
chartify makes) are invisible to `ShellRunner` instrumentation; options are a wrapper
span around each chartify call in `pkg/state`, or upstream OTel support in chartify.
- Log-to-trace correlation (zap OTel appender).
- Remaining per-release instrumentation: the flag-preparation loops
(`prepareSyncReleases`/`prepareDiffReleases`) and the exec nesting for
`helmexec.Interface` methods that take no `HelmContext`
(TemplateRelease/Lint/Unittest/Fetch) — their exec spans currently sit flat
under the load span while the release span covers the surrounding work.
- Fixing the kubedog/hook cancellation gaps (§4.3) — separate issues filed from this design.
## 13. Alternatives considered
| Alternative | Why rejected |
|---|---|
| Structured logs + collector-side parsing | No hierarchy/timing guarantees; every backend needs custom parsing; poor UX. |
| Prometheus metrics only | Shows counts/durations but not critical paths or nesting; the request is explicitly about tracing where time goes. |
| Hand-rolled exporter selection in helmfile | `autoexport` already exists in the dependency graph and implements the spec env vars (verified: v0.67.0 supports `otlp`/`console`/`none`, protocol dispatch, `none` detection); hand-rolled code is pure maintenance burden and would drift from the spec. |
| Helmfile-specific env vars for endpoint/headers etc. | Duplicates the OTel spec; standard vars are already what platform teams configure. |
| Full `context.Context` refactor of `pkg/state` first | Large, risky churn unrelated to the feature; the `HelmContext.Ctx`/`execCtx` design achieves nesting without it. |
| Using the existing `WithContext` clone for per-release spans | The cached, shared execer (`a.helms`) serves concurrent workers; per-release contexts must travel with the per-call `HelmContext` parameter, not via instance substitution. |
| Config-file (`helmfile.yaml`) telemetry settings | Telemetry is an operational/platform concern, not state authoring; flag+env matches how it's injected in CI. |
## 14. Open questions
1. **Propagation into helm subprocesses**: should helmfile inject `TRACEPARENT` into the
child process env (opt-in) so hooks/plugins can continue the trace? (§12)
2. **Span-name taxonomy stability**: do we commit to the §5 names as stable API for
dashboard authors during the experimental window, or reserve the right to rename?
Proposal: rename freely while experimental, freeze on promotion.
3. **`helmfile.d` parallel mode**: state-file spans are siblings under the root span — is a
synthetic `helmfile.parallel` grouping span wanted, or does flat suffice?
4. **Trace output when `--log-level=debug`**: duplicate a compact span tree to stderr at
shutdown for quick local triage without a collector?
5. **Root span noise for trivial commands**: `helmfile version`/`help` also run
`PersistentPreRunE` — export a root span for them, or skip? (Proposal: skip via a
short denylist; cosmetic.)
+14
View File
@@ -46,6 +46,20 @@ On `helmfile [delete|destroy]`, deletions happen in the reverse order.
That is, `myapp1` and `myapp2` are deleted first, then `servicemesh`, and finally `logging`.
### Failure handling and `continueOnError`
By default, `helmfile [sync|apply]` stops on the first release failure (fail-fast). Set `continueOnError: true` on a release to keep processing independent releases after it fails:
```yaml
- name: myapp1
chart: charts/myapp
continueOnError: true
needs:
- servicemesh
```
When a release with `continueOnError` fails, releases in other branches of the DAG are still processed, while releases that (transitively) depend on the failed release are skipped with an error like `release "myapp2" was skipped because dependency "myapp1" failed`. The command still exits non-zero, and Helm's own rollback behavior (`atomic`, `rollbackOnFailure`, ...) is unaffected. See [Continue On Error](configuration.md#continue-on-error) for details.
### Selectors and `needs`
When using selectors/labels, `needs` are ignored by default. This behaviour can be overruled with a few parameters:
+63 -1
View File
@@ -54,4 +54,66 @@ service:
login: svc-login # fetched from vault
password: pass
```
```
## Disabling vals
You can disable the built-in vals processing using environment variables:
### Pass-through mode
Set `HELMFILE_DISABLE_VALS=true` to disable internal vals processing. Any `ref+` values will pass through unchanged, allowing you to validate them with a policy tool such as [conftest](https://www.conftest.dev/) before they are resolved:
```bash
HELMFILE_DISABLE_VALS=true helmfile template | conftest test -
```
### Strict mode
Set `HELMFILE_DISABLE_VALS_STRICT=true` to disable vals and error if any `ref+` values are detected. This is useful when you want to prevent users from using vals references:
```bash
HELMFILE_DISABLE_VALS_STRICT=true helmfile sync
# Error: vals is disabled via HELMFILE_DISABLE_VALS_STRICT environment variable
```
Note: If both are set, strict mode takes precedence.
Strict mode detects any `ref+<provider>://` or `secretref+<provider>://` expression, including nested ones in maps and arrays. Plain strings that merely contain the text `ref+` (without a provider scheme) are not vals references and do not trigger the error.
### Validating ref+ expressions with conftest
You can use `HELMFILE_DISABLE_VALS=true` with [conftest](https://www.conftest.dev/) to validate that all `ref+` expressions conform to your security policy before processing them.
Example rego policy (`policy/vals_refs.rego`):
```rego
package main
allowed_refs := {
"ref+tfstates3://my-terraform-state/networking/eu-west-2/vpc/vpc_id",
"ref+tfstates3://my-terraform-state/networking/eu-west-2/vpc/private_subnet_ids",
"ref+tfstates3://my-terraform-state/platform/eu-west-2/eks/cluster_endpoint",
}
deny[msg] {
value := input[_]
startswith(value, "ref+tfstates3://")
not allowed_refs[value]
msg := sprintf("ref+ expression references an unapproved tfstates3 URI: %s", [value])
}
deny[msg] {
value := input[_]
startswith(value, "ref+")
not startswith(value, "ref+tfstates3://")
msg := sprintf("only tfstates3 ref+ expressions are permitted, got: %s", [value])
}
```
Run against your rendered values:
```bash
HELMFILE_DISABLE_VALS=true helmfile template | conftest test -
```
+1 -1
View File
@@ -2,7 +2,7 @@ Babel==2.17.0
click==8.1.2
ghp-import==2.0.2
gitdb==4.0.9
GitPython==3.1.58
GitPython==3.1.62
importlib-metadata==4.11.3
Jinja2==3.1.6
Markdown==3.8.1
+2
View File
@@ -62,6 +62,8 @@ Helmfile uses some OS environment variables to override default behaviour:
* `HELMFILE_DISABLE_INSECURE_FEATURES` - disable insecure features, expecting `true` lower case
* `HELMFILE_DISABLE_RUNNER_UNIQUE_ID` - disable unique logging ID, expecting any non-empty value
* `HELMFILE_DISABLE_VALS` - disable internal vals processing, `ref+` values pass through unchanged for use with external vals, expecting `true` lower case
* `HELMFILE_DISABLE_VALS_STRICT` - disable vals and error if any `ref+` values are detected, expecting `true` lower case
* `HELMFILE_SKIP_INSECURE_TEMPLATE_FUNCTIONS` - disable insecure template functions, expecting `true` lower case
* `HELMFILE_USE_HELM_STATUS_TO_CHECK_RELEASE_EXISTENCE` - expecting non-empty value to use `helm status` to check release existence, instead of `helm list` which is the default behaviour
* `HELMFILE_EXPERIMENTAL` - enable experimental features, expecting `true` lower case
+200 -176
View File
@@ -1,26 +1,30 @@
module github.com/helmfile/helmfile
go 1.26.2
go 1.27.1
require (
dario.cat/mergo v1.0.2
github.com/Masterminds/semver/v3 v3.5.0
github.com/Masterminds/sprig/v3 v3.3.0
github.com/aws/aws-sdk-go-v2/config v1.32.38
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
github.com/aws/aws-sdk-go-v2/config v1.33.9
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/go-test/deep v1.1.1
github.com/gofrs/flock v0.13.0
github.com/fatih/color v1.19.0
github.com/go-test/deep v1.1.2
github.com/gofrs/flock v0.13.1
github.com/golang/mock v1.6.0
github.com/google/go-cmp v0.7.0
github.com/gookit/color v1.6.1
github.com/gookit/color v1.6.3
github.com/gosuri/uitable v0.0.4
github.com/hashicorp/go-cty-funcs v0.1.0
github.com/hashicorp/go-getter/v2 v2.2.3
github.com/hashicorp/hcl/v2 v2.24.0
github.com/helmfile/chartify v0.28.2
github.com/helmfile/vals v0.46.0
github.com/sashabaranov/go-openai v1.42.0
github.com/hashicorp/go-getter/v2 v2.2.5
github.com/hashicorp/hcl/v2 v2.25.0
github.com/helmfile/chartify v0.29.0
github.com/helmfile/vals v0.47.0
github.com/mattn/go-runewidth v0.0.31
github.com/otiai10/copy v1.14.1
github.com/pkg/errors v0.9.1
github.com/sashabaranov/go-openai v1.43.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.12.1
@@ -30,113 +34,60 @@ require (
github.com/werf/kubedog v0.13.1-0.20260217150136-ed58edf34eac
github.com/zclconf/go-cty v1.19.0
github.com/zclconf/go-cty-yaml v1.2.0
go.opentelemetry.io/contrib/exporters/autoexport v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/metric v1.47.0
go.opentelemetry.io/otel/sdk v1.47.0
go.opentelemetry.io/otel/sdk/metric v1.47.0
go.opentelemetry.io/otel/trace v1.47.0
go.opentelemetry.io/proto/otlp v1.11.1
go.szostok.io/version v1.2.0
go.uber.org/zap v1.28.0
go.yaml.in/yaml/v2 v2.4.4
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
golang.org/x/sync v0.24.0
golang.org/x/term v0.47.0
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1
helm.sh/helm/v3 v3.21.4
helm.sh/helm/v4 v4.2.4
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
helm.sh/helm/v3 v3.22.1
helm.sh/helm/v4 v4.3.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.1
)
replace (
// kubedog's flagger dependency still imports k8s.io/api/autoscaling/v2beta2,
// while Helm 4.2.2 requires k8s.io/apimachinery/content.IsPathSegmentName from v0.36+.
// which was removed in k8s.io/api v0.36+, while Helm 4 requires
// k8s.io/apimachinery/content.IsPathSegmentName from v0.36+.
k8s.io/api => k8s.io/api v0.35.4
k8s.io/apimachinery => k8s.io/apimachinery v0.36.0
k8s.io/client-go => k8s.io/client-go v0.35.4
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/storage v1.64.0 // indirect
filippo.io/age v1.3.1 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/a8m/envsubst v1.4.3 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/fatih/color v1.19.0
github.com/fujiwara/tfstate-lookup v1.12.1 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-slug v0.16.8 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-tfe v1.103.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e // indirect
github.com/hashicorp/vault/api v1.23.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/gojq v0.12.19 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-runewidth v0.0.28
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/otiai10/copy v1.14.1
github.com/pkg/errors v0.9.1
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.291.0 // indirect
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
// controller-runtime v0.25.x (required by Helm 4.3.1) needs k8s.io/client-go >= v0.36
// for toolscache.DoneChecker, which conflicts with the client-go pin above.
// Helm only uses pkg/client/apiutil, whose API is unchanged since v0.24.x.
sigs.k8s.io/controller-runtime => sigs.k8s.io/controller-runtime v0.24.1
)
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
cel.dev/expr v0.25.2 // indirect
cloud.google.com/go/auth v0.22.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.12.0 // indirect
cloud.google.com/go/kms v1.33.0 // indirect
cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/monitoring v1.30.0 // indirect
cloud.google.com/go/secretmanager v1.21.0 // indirect
cloud.google.com/go/storage v1.67.1 // indirect
filippo.io/age v1.3.2 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/1Password/connect-sdk-go v1.5.3 // indirect
github.com/1password/onepassword-sdk-go v0.3.1 // indirect
github.com/1password/onepassword-sdk-go v0.4.1 // indirect
github.com/AlecAivazis/survey/v2 v2.3.6 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 // indirect
@@ -145,16 +96,25 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.23 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect
github.com/Azure/go-autorest/logger v0.2.1 // indirect
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/DelineaXPM/tss-sdk-go/v3 v3.0.2 // indirect
github.com/DelineaXPM/delinea-common v1.0.0 // indirect
github.com/DelineaXPM/tss-sdk-go/v3 v3.1.0 // indirect
github.com/DopplerHQ/cli v0.5.11-0.20230908185655-7aef4713e1a4 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/squirrel v1.5.4 // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/a8m/envsubst v1.4.3 // indirect
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/antchfx/jsonquery v1.3.7 // indirect
@@ -166,27 +126,30 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 // indirect
github.com/aws/aws-sdk-go-v2 v1.43.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.37 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38 // indirect
github.com/aws/aws-sdk-go-v2 v1.47.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 // indirect
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.73.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 // indirect
github.com/aws/smithy-go v1.27.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/service/kms v1.60.0 // indirect
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.49.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssm v1.76.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect
github.com/aws/smithy-go v1.28.5 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
@@ -198,12 +161,13 @@ require (
github.com/cloudflare/circl v1.6.4 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cyberark/conjur-api-go v0.15.5 // indirect
github.com/cyberark/conjur-api-go v0.15.7 // indirect
github.com/cyphar/filepath-securejoin v0.7.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dominikbraun/graph v0.23.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
@@ -212,9 +176,10 @@ require (
github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
github.com/extism/go-sdk v1.7.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fluxcd/cli-utils v1.2.1 // indirect
github.com/fluxcd/cli-utils v1.2.2 // indirect
github.com/fluxcd/flagger v1.36.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fujiwara/tfstate-lookup v1.12.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e // indirect
github.com/getsops/sops/v3 v3.13.2 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
@@ -223,74 +188,102 @@ require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.25.5 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/loads v0.25.0 // indirect
github.com/go-openapi/runtime v0.33.0 // indirect
github.com/go-openapi/runtime/server-middleware v0.30.0 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/strfmt v0.27.0 // indirect
github.com/go-openapi/swag v0.24.1 // indirect
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
github.com/go-openapi/swag/conv v0.27.3 // indirect
github.com/go-openapi/swag/fileutils v0.27.3 // indirect
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.3 // indirect
github.com/go-openapi/swag/loading v0.27.3 // indirect
github.com/go-openapi/swag/mangling v0.27.3 // indirect
github.com/go-openapi/swag/netutils v0.24.0 // indirect
github.com/go-openapi/swag/pools v0.27.3 // indirect
github.com/go-openapi/swag/stringutils v0.27.3 // indirect
github.com/go-openapi/swag/typeutils v0.27.3 // indirect
github.com/go-openapi/swag/yamlutils v0.27.3 // indirect
github.com/go-openapi/validate v0.26.1 // indirect
github.com/go-openapi/analysis v1.0.0 // indirect
github.com/go-openapi/errors v0.22.9 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.1 // indirect
github.com/go-openapi/loads v0.25.2 // indirect
github.com/go-openapi/runtime v0.33.2 // indirect
github.com/go-openapi/runtime/server-middleware v0.33.2 // indirect
github.com/go-openapi/spec v1.0.0 // indirect
github.com/go-openapi/strfmt v0.27.2 // indirect
github.com/go-openapi/swag v0.29.2 // indirect
github.com/go-openapi/swag/cmdutils v0.29.2 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/fileutils v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
github.com/go-openapi/swag/loading v0.29.2 // indirect
github.com/go-openapi/swag/mangling v0.29.2 // indirect
github.com/go-openapi/swag/netutils v0.29.2 // indirect
github.com/go-openapi/swag/pools v0.29.2 // indirect
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-openapi/validate v1.0.0 // indirect
github.com/go-resty/resty/v2 v2.13.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-jsonnet v0.22.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.19 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect
github.com/googleapis/gax-go/v2 v2.24.0 // indirect
github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-rootcerts v1.0.2 // indirect
github.com/hashicorp/go-safetemp v1.0.0 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
github.com/hashicorp/go-slug v0.16.8 // indirect
github.com/hashicorp/go-sockaddr v1.0.7 // indirect
github.com/hashicorp/go-tfe v1.103.0 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcp-sdk-go v0.174.0 // indirect
github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
github.com/hashicorp/hcp-sdk-go v0.175.0 // indirect
github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e // indirect
github.com/hashicorp/vault/api v1.23.0 // indirect
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.202 // indirect
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca // indirect
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/infisical/go-sdk v0.8.0 // indirect
github.com/itchyny/gojq v0.12.19 // indirect
github.com/itchyny/timefmt-go v0.1.8 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
github.com/muesli/termenv v0.15.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/openbao/openbao/api/v2 v2.6.0 // indirect
github.com/oklog/ulid/v2 v2.1.2 // indirect
github.com/openbao/openbao/api/v2 v2.7.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
@@ -299,14 +292,24 @@ require (
github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.72.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.22.0 // indirect
github.com/rs/zerolog v1.26.1 // indirect
github.com/rubenv/sql-migrate v1.8.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
github.com/samber/lo v1.39.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.10.2 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v0.5.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
@@ -316,6 +319,7 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/urfave/cli v1.22.17 // indirect
github.com/werf/logboek v0.6.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -324,45 +328,65 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yandex-cloud/go-genproto v0.95.0 // indirect
github.com/yandex-cloud/go-sdk v0.32.0 // indirect
github.com/yandex-cloud/go-genproto v0.121.0 // indirect
github.com/yandex-cloud/go-sdk v0.35.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.69.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 // indirect
go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/sdk/log v1.47.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/oauth2 v0.37.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.51.0 // indirect
google.golang.org/api v0.296.0 // indirect
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect
google.golang.org/grpc v1.84.0 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/gookit/color.v1 v1.1.6 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.67.3 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/api v0.36.3 // indirect
k8s.io/apiextensions-apiserver v0.36.2 // indirect
k8s.io/apiserver v0.36.2 // indirect
k8s.io/cli-runtime v0.36.2 // indirect
k8s.io/component-base v0.36.2 // indirect
k8s.io/api v0.37.1 // indirect
k8s.io/apiextensions-apiserver v0.37.1 // indirect
k8s.io/apiserver v0.37.1 // indirect
k8s.io/cli-runtime v0.37.1 // indirect
k8s.io/component-base v0.37.1 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/kubectl v0.36.2 // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/kubectl v0.37.1 // indirect
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
oras.land/oras-go/v2 v2.6.2 // indirect
sigs.k8s.io/controller-runtime v0.24.1 // indirect
sigs.k8s.io/controller-runtime v0.25.2 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/kustomize/api v0.21.1 // indirect
sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect
sigs.k8s.io/kustomize/kyaml v0.21.2 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
+296 -298
View File
@@ -1,38 +1,38 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.22.0 h1:Xp9wAKkLoeaYb5pYZZoQGz4E9sdPxIbzS3gywZE3ciQ=
cloud.google.com/go/auth v0.22.0/go.mod h1:M9o2Oz+YI2jAfxewJgb1vyI3vceHF+eohmxyzmrl+9s=
cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/iam v1.12.0 h1:Aki3bX9aHUDKPHfnRJfDcTdVedvy6quGBQcTqx3DRXk=
cloud.google.com/go/iam v1.12.0/go.mod h1:FEZ4lXpADAC2AIpQY7LANNjjwyQ2jK439CI2VaD+sLY=
cloud.google.com/go/kms v1.33.0 h1:pG0X78m212b2pv9N4fdMoUO69LuZGQ9kSvn8sHBOFAo=
cloud.google.com/go/kms v1.33.0/go.mod h1:CSGvW6GnMQbY+1nOHcIzhMtHSbExXlOmCKjWtYVjcpA=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
cloud.google.com/go/logging v1.19.0 h1:NCqhdVUg3wQ8Cobdf16FDSuTGi3+6+hdSBHrY5TsR6Q=
cloud.google.com/go/logging v1.19.0/go.mod h1:i40NZCHC9Gqvod4yE+yQfDWwlgwW/SrshkkGibCHxcA=
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/secretmanager v1.21.0 h1:e56QQaKWRyzBdUz40AeZaio/ZHAl268cFx3QFAAw9CY=
cloud.google.com/go/secretmanager v1.21.0/go.mod h1:+nlV+GYqTD8DM+x7Kk3UF7ZPYgdYMowrkZxAmMXORQ8=
cloud.google.com/go/storage v1.64.0 h1:KLpxI/oX9LxeRsNqn877d2WyeT3ryiEwnGt8pwcSPZg=
cloud.google.com/go/storage v1.64.0/go.mod h1:lWyAtwvDZHdL3k68WVKbESP6bmWaV23ZJJ/JEVw/ZaQ=
cloud.google.com/go/storage v1.67.1 h1:cDeyjY1ik/2IBs4/nLytL6Orrtbi5M0G6beyGRl/9EQ=
cloud.google.com/go/storage v1.67.1/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM=
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
@@ -40,14 +40,14 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/1Password/connect-sdk-go v1.5.3 h1:KyjJ+kCKj6BwB2Y8tPM1Ixg5uIS6HsB0uWA8U38p/Uk=
github.com/1Password/connect-sdk-go v1.5.3/go.mod h1:5rSymY4oIYtS4G3t0oMkGAXBeoYiukV3vkqlnEjIDJs=
github.com/1password/onepassword-sdk-go v0.3.1 h1:dz0LrYuIh/HrZ7rxr8NMymikNLBIXhyj4NBmo5Tdamc=
github.com/1password/onepassword-sdk-go v0.3.1/go.mod h1:kssODrGGqHtniqPR91ZPoCMEo79mKulKat7RaD1bunk=
github.com/1password/onepassword-sdk-go v0.4.1 h1:My/Q2QXemep0I0qHgGrOs7EEzpPh2QZ1/II+S3YqOG0=
github.com/1password/onepassword-sdk-go v0.4.1/go.mod h1:j/CbzhucTywjlYrd6SE6k0LcQaFZ2l8OLBsAsOYtvD0=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw=
github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 h1:aokoqcHvaGjiM3VpjKDfMMnF/8epJ+Q1HLJ7CudztqE=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0/go.mod h1:/WYEx9pcM9Y+Dd/APJaNlSvVSvzl54rrMdZT5+Oi2LM=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1 h1:zvXfGJCWvywnCA814d8ZiVyt+fm9nnTE8xSb99zRyfo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.23.1/go.mod h1:iptorS+VYKFL2N6PnebpS91dubG35eAOEERnT4PJbQU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 h1:CU4+EJeJi3TKYWEcYuSdWsjzw0nVsK/H0MSQOiPcymU=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0/go.mod h1:q0+UTSRvShwUCrR/s5HtyInYphN7Wvxb7snFM3u+SLA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.4.0 h1:xFaZZ+IubdftrDHnGGwZ6QvQ3KHTtWl2MCK+GMt2vxs=
@@ -96,12 +96,14 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/DelineaXPM/tss-sdk-go/v3 v3.0.2 h1:8wRzxlo6fujNoDbnp6PnawY3moxqQelxpJGzTHG7Qoo=
github.com/DelineaXPM/tss-sdk-go/v3 v3.0.2/go.mod h1:VmyoHQ25FhSVHTI3/ptQNOviNEMfCy2ALAf/3E4Eqxg=
github.com/DelineaXPM/delinea-common v1.0.0 h1:JcD1b9uGxVpWORls6fQ4KkDGNZgGlfPPZfH5UAd4YiI=
github.com/DelineaXPM/delinea-common v1.0.0/go.mod h1:P/a9zx/q5x1GxsICEZ+xS+DcZgMN5cE2zk/mT8WuhPE=
github.com/DelineaXPM/tss-sdk-go/v3 v3.1.0 h1:Z8Npn0lGxAjYMgR5Wu/Di8OCfD2e1A1pnVUVL8TbYDU=
github.com/DelineaXPM/tss-sdk-go/v3 v3.1.0/go.mod h1:RUucah2faoja0H6kqzuAdgm6KgwYtC2spZfWZlVCgqY=
github.com/DopplerHQ/cli v0.5.11-0.20230908185655-7aef4713e1a4 h1:s7/zwMi5w+KnlumDVbX1+P6mNAk5o7Wvx0VmvrQ7Bm0=
github.com/DopplerHQ/cli v0.5.11-0.20230908185655-7aef4713e1a4/go.mod h1:ipnA9Lpn5YM+FDSQZ7VWNjcuVurchInoGKm+v7O0sGs=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0 h1:yzIYdwuro811Z27D3T80Wkd3rqZzb0K43nner7Eh1yE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.34.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
@@ -155,50 +157,50 @@ github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774 h1:HrMVYtly2IVqg9E
github.com/avelino/slugify v0.0.0-20180501145920-855f152bd774/go.mod h1:5wi5YYOpfuAKwL5XLFYopbgIl/v7NZxaJpa/4X6yFKE=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ=
github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM=
github.com/aws/aws-sdk-go-v2/config v1.32.38 h1:n4yPHBjtQ3BrIIUyk0/LAqf/BL2iv0Tw6XZcMRzM0ps=
github.com/aws/aws-sdk-go-v2/config v1.32.38/go.mod h1:dencYsOS1R7rBy8zehCvwBYzdxxL4Q/nRK7In03wjN8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.37 h1:FJ8Iz4/xISMB/rwLlgfWujfGDFWr0oneQgtA6KPcYLY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.37/go.mod h1:Q6pWOgVUp49x4g5QVi29wHofUoICnZ+Zq4jHbRN/7ec=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38 h1:Nqo2jU1wz5rnBM9XQyXfVD1RP8txkbP3EDx8hR/hbCE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.38/go.mod h1:PzJFHhjR2vWFKHe8HmY5Lxhvwyxnr5MERtk0nDxWNbk=
github.com/aws/aws-sdk-go-v2 v1.47.3 h1:HRoT/MkNi3bjO5xF4dCzLzvVmkvRGHj3/cLINDnJejk=
github.com/aws/aws-sdk-go-v2 v1.47.3/go.mod h1:xSl+OSI5ZUw6bSMupWc218zE5Pl+65MCCu3ySWFY99A=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22 h1:oiEmPz/YMMeh/JvAE8xSxNCIsvWE6lpFe4ZPlyFykQI=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.22/go.mod h1:QWUDuRi9PFjBHIU2iH1Pkldbe7zqLfXJC4wWLbF8nuQ=
github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0=
github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.28 h1:ez4y5o7sa0uaRI8BquYOXtZpioUPhbQEh7Igm88oV9U=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.28/go.mod h1:TpmZOrQA12XKEpVypgBGZSQBsm1WUTndCiSnbDsbvug=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 h1:vo4xvMRs/F6h1E52qsgLqCQgWIQXgIJUauG6rlZEh4U=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39/go.mod h1:jB03R1ij/A+OE2e1dz6vgj076gd7vlYcfstAzj3HcnU=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 h1:uZOinZb+h7lZw8IYzP1z1IuEnueB76/EFkcf/fEW4Ag=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31/go.mod h1:NRtwAM/p5VRt03TlEUs0pH3TeWamWdf4YyJpSrzPYLc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 h1:H/5TI1jqaHsNoDQ60UwvPvJBg4GURkinXI3Qga29t2w=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38/go.mod h1:PTVFf+XH++7NJOky+RLBYQx0QA5NcaeEYFQ2fsi0nwo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 h1:HLPAVrlLDaN2boN0xJx7MgaQDNEO3Q+c9L6kl/8m47Q=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39/go.mod h1:Pg/dVfsNkm1hsIDK/gMvCKtmyNfNTV12mrgHqVE/6Oo=
github.com/aws/aws-sdk-go-v2/service/kms v1.54.1 h1:aeJAJyvWS3gQ679pJbz8ZdOh3MViD1zvEdoZMVEawbg=
github.com/aws/aws-sdk-go-v2/service/kms v1.54.1/go.mod h1:0RXNc6Yf3AvSMldGD6Lcch96Ojlw2TtGnHsqfD/L4u8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ7QgB1S5tDAyPBQQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.1 h1:TpALnO2Chp6wQOihK/v+qoPN80YOyu3hG/MQ9dG+DGo=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.44.1/go.mod h1:nfho2mqRsESddA8OHQiiqhNKaZtGW1Pe1sf6RJFYnfE=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.7 h1:YcczQ6zNH/ojIzD/ikDrO+RfW06wmdMp18d4NH5hXY4=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.7/go.mod h1:nl9RVnb9ulgAYzOkjLq1NyFxmWcnH2maCUEuOdESy98=
github.com/aws/aws-sdk-go-v2/service/ssm v1.73.0 h1:8AE9z5vMHNC7tQuaje8fSsNZyvj+0ttiQ2Ed/8rLBsc=
github.com/aws/aws-sdk-go-v2/service/ssm v1.73.0/go.mod h1:004bP6yJs8vdEpZwBT3H25GzleBVJYgeT2pPXkU4t4g=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.7 h1:P+bMNiA93gyuYT3Oh+4dWtvrnGcu2bd9Uy5hRJM8BNo=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.7/go.mod h1:zy+397isDFLvleg9H18Zq2MGzMso7uKyJyzR7DWSgFk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7 h1:WWkehGZ4nWtOKLMy0yi8+RqzzVqAGe60hGaxwF06JAw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.7/go.mod h1:T8AI4SbQYm9ybcVmki2T3n7Qg1g3kfWoeQlNwNYOyO8=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.7 h1:yU/9y2r7s9kSUPbHXbpQTa4LA8kt+CMgpu1OBrhx8p4=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.7/go.mod h1:0lQTDEBArMevQXpxu443LVGjKxxEeSsSnrw9n8YiTMg=
github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6 h1:CqL8NN3p4I0JTwwV5MEoatIl4Y2amZ2X9dRD+0hbRL0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.6/go.mod h1:exdJ32hMqXl0Wyo6NwqEC5yIhtua3+PDaKE8P29m4qE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 h1:FpBq24TG6WVSObTNhQOv7Q0088rounUUlwn3zlNItkM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6/go.mod h1:ONzdPVYC95oBOu0VWNEsKEntcoemP3xVKjT+qWml1s4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
github.com/aws/aws-sdk-go-v2/service/kms v1.60.0 h1:O/xKUsrz7J69Q0yca9XeYxwgi+NbBVbxczcZWudJdU0=
github.com/aws/aws-sdk-go-v2/service/kms v1.60.0/go.mod h1:/eZ9xj8IZyb7fj+/t3HO7x89X+7Vn+gCDDbQ2NHCVtM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.49.0 h1:XkHFx2EDC1VxiXaPVBkq3NWD4O+geADhelhl/nvr2Q0=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.49.0/go.mod h1:HMOw7but3OQg86ARfV8Hvoc8h/kNiB3OQm1q6AwO27I=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M=
github.com/aws/aws-sdk-go-v2/service/ssm v1.76.0 h1:hdrI7EQp8HQzmxSwsXsWhjo7AwcVsw8rC/5AOCiDwQ4=
github.com/aws/aws-sdk-go-v2/service/ssm v1.76.0/go.mod h1:l+OkBEbZ5r0XEHQ+R7c27Zw3LO89fau+FBxsDoAW1TU=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM=
github.com/aws/smithy-go v1.28.5 h1:OqbqKtzY9NUnr2yAe9+hiTdFb20tNaK/lI0PMOGXX9k=
github.com/aws/smithy-go v1.28.5/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -209,8 +211,6 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bshuster-repo/logrus-logstash-hook v1.1.0 h1:o2FzZifLg+z/DN1OFmzTWzZZx/roaqt8IPZCIVco8r4=
github.com/bshuster-repo/logrus-logstash-hook v1.1.0/go.mod h1:Q2aXOe7rNuPgbBtPCOzYyWDvKX7+FpxE5sRdvcPoui0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -247,8 +247,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cyberark/conjur-api-go v0.15.5 h1:dGf5QDtSmNX8LdIE3x3hGnOg5FdnwM/QTsBb5GtGi5I=
github.com/cyberark/conjur-api-go v0.15.5/go.mod h1:IxsTkDhEewa3iU/W7DMaJ6/snX208F4PYVTjzofRjzc=
github.com/cyberark/conjur-api-go v0.15.7 h1:8bOdz6KpujabuQGXMIc9/ejZ6wCHQtMtQnrHw9KSYag=
github.com/cyberark/conjur-api-go v0.15.7/go.mod h1:IxsTkDhEewa3iU/W7DMaJ6/snX208F4PYVTjzofRjzc=
github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE=
github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4=
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
@@ -261,8 +261,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
github.com/distribution/distribution/v3 v3.1.1 h1:KUbk7C8CfaLXy8kbf/hGq9cad/wCoLB6dbWH6DMbmX0=
github.com/distribution/distribution/v3 v3.1.1/go.mod h1:d7lXwZpph0bVcOj4Aqn0nMrWHIwRQGdiV5TLeI+/w6Y=
github.com/distribution/distribution/v3 v3.1.2 h1:/Bv2YIqqSR00HiO49FT93hovy6r9IlrzwUDsJjCN5qg=
github.com/distribution/distribution/v3 v3.1.2/go.mod h1:Oz0Drs2Tx3iTXzK3DhUD4otfTfqlCxlWVC7QJeXWBP0=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
@@ -275,16 +275,16 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-events v0.0.0-20250808211157-605354379745 h1:yOn6Ze6IbYI/KAw2lw/83ELYvZh6hvsygTVkD0dzMC4=
github.com/docker/go-events v0.0.0-20250808211157-605354379745/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
github.com/docker/go-metrics v0.1.0 h1:r76KPNpstz+IvQKSWpYegSkkyzex0V3A1ZGVx6bhGlY=
github.com/docker/go-metrics v0.1.0/go.mod h1:PciI3sONtB051kXALN1JoIlpcu54E1FuPh+4DuqEzyw=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE=
github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY=
github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -311,8 +311,8 @@ github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/fluxcd/cli-utils v1.2.1 h1:ug9CicKW7H9QXnvNDapTSKuryZvWcu4Nw7pRvQa6jDY=
github.com/fluxcd/cli-utils v1.2.1/go.mod h1:cky6M6eHvTQkoPtsuFYLIgAMYdpTCSLoor4IA6vueSw=
github.com/fluxcd/cli-utils v1.2.2 h1:adDOmwE+LSwTzmYUaoEFPblruOuaQEKAg1ZNTmPJObE=
github.com/fluxcd/cli-utils v1.2.2/go.mod h1:FsghNGY+3Sr70c0FOB7I5So0kzoYVdvQ8GTid3XXVWM=
github.com/fluxcd/flagger v1.36.1 h1:X2PumtNwZz9YSGaOtZLFm2zAKLgHhFkbNv8beg7ifyc=
github.com/fluxcd/flagger v1.36.1/go.mod h1:qmtLsxheVDTI8XeCaXUxW5UCmfcSKnY9fizG9NmW/Fk=
github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0=
@@ -321,8 +321,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fujiwara/tfstate-lookup v1.12.1 h1:WQ0/+wYDm5yeiQtfejriKHonIXH6DSGkCJXEX8cV3hw=
github.com/fujiwara/tfstate-lookup v1.12.1/go.mod h1:c7Nd3ZdQ2UljabuWzhgDaseKZhdXfgs6YLFarGbXyDs=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e h1:y/1nzrdF+RPds4lfoEpNhjfmzlgZtPqyO3jMzrqDQws=
github.com/getsops/gopgagent v0.0.0-20241224165529-7044f28e491e/go.mod h1:awFzISqLJoZLm+i9QQ4SgMNHDqljH6jWV0B36V5MrUM=
github.com/getsops/sops/v3 v3.13.2 h1:jjeipXFlUoBIjBL6Dm1HSsCOOjJZKTq7mx0nVfkcLxY=
@@ -342,58 +342,56 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI=
github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc=
github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I=
github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI=
github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8=
github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU=
github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw=
github.com/go-openapi/runtime/server-middleware v0.30.0 h1:8rPoJ/xv7JL8BsovaqboKETlpWBArVh8n+0L/GyePog=
github.com/go-openapi/runtime/server-middleware v0.30.0/go.mod h1:OYNT/TxNvB/VK5oe4htM2jDTwlEXuejVJmu0DVZfAMs=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM=
github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc=
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
github.com/go-openapi/swag/conv v0.27.3 h1:iqJFmGEjmX3AY0lSszABFqRVqOSt99XS0LzNIMJYuhU=
github.com/go-openapi/swag/conv v0.27.3/go.mod h1:nPRmN6jgNme99hpf+nM0auDZGALWIqlwhisKPK/bQhQ=
github.com/go-openapi/swag/fileutils v0.27.3 h1:3UVoZ2RLaIs1lt+2jcKzL8RM3Yk0rmsDE9FLA/HGxFE=
github.com/go-openapi/swag/fileutils v0.27.3/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE=
github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc=
github.com/go-openapi/swag/jsonutils v0.27.3 h1:1DEz+O82frtSMBcos/7XIn1GnpNTbsD4Bru4Dc/uhRc=
github.com/go-openapi/swag/jsonutils v0.27.3/go.mod h1:qiDCoQvzkMxrV3G8FLEdIU5L+EFYc0zcDOHWT3Yofvo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3 h1:h/eT9kmGCDdFLJF29lOhzLtF0FmP1AX2MhLJWVebsb8=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.3/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.27.3 h1:L9nQkEgzU7QgFQL+pLEMfGUKxeM4pWwGwbET9Z3weW0=
github.com/go-openapi/swag/loading v0.27.3/go.mod h1:rJ0NeaKsF4CVPnMGjPQl7JlSHzvD0bc2DKXLss1hiuE=
github.com/go-openapi/swag/mangling v0.27.3 h1:gRzzD1PAUoLTtGMgI3KpBmCSOlTuLTFWnviLxLcTnyg=
github.com/go-openapi/swag/mangling v0.27.3/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
github.com/go-openapi/swag/pools v0.27.3 h1:gXjImP3F6/56wRRcFgEPld084Y6u2gs21ikPBt8NKBk=
github.com/go-openapi/swag/pools v0.27.3/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.3 h1:Ru28hnbAvN5wycALQYy8IobHvASq+FUFMlp1QzLM0JI=
github.com/go-openapi/swag/stringutils v0.27.3/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.3 h1:l6SSrx5eR5/WVwrGNzN6bQ9WqL04mrxNBl9YgQ3rcJ4=
github.com/go-openapi/swag/typeutils v0.27.3/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.3 h1:cRFCAoYtslYn9L9T0xWryHy1t7c1MACC+DMj3CLvwvs=
github.com/go-openapi/swag/yamlutils v0.27.3/go.mod h1:6JYBGj8sw/NawMllyZY+cTA8Mzk2etS3ZBASdcyPsiU=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us=
github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo=
github.com/go-openapi/analysis v1.0.0 h1:sNvbAGCJqUTqIAodr9IVqJMmuZas3YS9ms1dGK9yiJ4=
github.com/go-openapi/analysis v1.0.0/go.mod h1:NhYjJ57fnE+bcE7UwrJyMkhWA3Dfz7TdiBfTVAnos4Y=
github.com/go-openapi/errors v0.22.9 h1:HI9+SyVYiRzyeBQGv0CbWbQa+u2S0nny7G7AhY+UMiM=
github.com/go-openapi/errors v0.22.9/go.mod h1:R73q66sXP2smzAGgFOI4nqtusRK2xN5pYCjsij+KX58=
github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
github.com/go-openapi/jsonreference v1.0.1 h1:4zJ7AmYDKNmD3aSpfPnFNCFA5E80/xMHUNKgydaLh38=
github.com/go-openapi/jsonreference v1.0.1/go.mod h1:dYplQXa6p5lXprLcJ8LE2iU7vNpXsAHDQ5ZAgL+Qx3A=
github.com/go-openapi/loads v0.25.2 h1:+uNsDlRQfYtZTrh+3pdwampcAqZVPuBJW0IA82aZHII=
github.com/go-openapi/loads v0.25.2/go.mod h1:RXsfJEQGGNv4uw8u/KdmF8Vh8OR4fTfs9ZO4QrzNohA=
github.com/go-openapi/runtime v0.33.2 h1:HSxskMs0WmpCdQvBWVxHt2t2mXMwn8DDav3VtVidwig=
github.com/go-openapi/runtime v0.33.2/go.mod h1:NQpSLiIsEAIpZDEs6xUrhjm6ZiQh85X8bHKlqLPgNP8=
github.com/go-openapi/runtime/server-middleware v0.33.2 h1:BVFjAaW4Jh/kZ4QSsgDrGzLuSuaKzSiyvappUFKxgUk=
github.com/go-openapi/runtime/server-middleware v0.33.2/go.mod h1:E3mWY61/UgBJ5EUmKh+h6A1oQQdD1U4raowVMmPmQug=
github.com/go-openapi/spec v1.0.0 h1:JtB/GHOj+eetjse6YvxqLze88oEekl/4uPBethvzRrA=
github.com/go-openapi/spec v1.0.0/go.mod h1:boj1PRhqS0x5jylgcNp9BRWxenphrh7vVNYZ90IRCoo=
github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ=
github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E=
github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4=
github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo=
github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro=
github.com/go-openapi/swag/cmdutils v0.29.2/go.mod h1:XziaSVzYqkDvpWXzASfWajw2YvJrLscq610O2XiyqJw=
github.com/go-openapi/swag/conv v0.29.2 h1:8c9shoB8l0QRSR6ymq1llHdBWDqpIgJfjTGHx3Vuhm0=
github.com/go-openapi/swag/conv v0.29.2/go.mod h1:AZS0YigTNf8qNtD6WqJz/N4RUNmpr76SyjFGkq4+p6Q=
github.com/go-openapi/swag/fileutils v0.29.2 h1:mdUL+Vw5ah1fO1AvFCoHeIyv7YZsCz2MN3KNqd/lLVI=
github.com/go-openapi/swag/fileutils v0.29.2/go.mod h1:7QKmmodjAebaq61lGVJa8ldLdGeMF4DSgYv8tXOGGo0=
github.com/go-openapi/swag/jsonutils v0.29.2 h1:uZNSD2/rJDYAfvsLYkykTzXuyxM3QpDKV9jhRHrMu6k=
github.com/go-openapi/swag/jsonutils v0.29.2/go.mod h1:ONTdNvj3Y+IXRzfa17E+Rgt2ns/qiSOYjIo2K7v2yDs=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2 h1:w+Fd6EBOMGlC74GYGHqCLGyb3Bpams8TtNrgM/SG4jo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.29.2/go.mod h1:HC2egPORFzbj/gf05Zrfgz8Mgplx3oW7hgGi7pjZVhM=
github.com/go-openapi/swag/loading v0.29.2 h1:QU1ry24e6r6Shoxe380Nx0X5iKkpRO+hJffsyJ2dVlY=
github.com/go-openapi/swag/loading v0.29.2/go.mod h1:DqilDjuiJKETecsS4TRopWG1acUDsfqN39ZmQhPu6uI=
github.com/go-openapi/swag/mangling v0.29.2 h1:ltdo3K0tTP4P6zWhVVWDU9D37/YYBuMJRdZxiS2Dtog=
github.com/go-openapi/swag/mangling v0.29.2/go.mod h1:RjDi4TnItAenS3cgCXSpIl1kKbjG7il6pnmQVl/8bp8=
github.com/go-openapi/swag/netutils v0.29.2 h1:dBli6jyUa93sDS/XYWShJzf4+nnVfFKfvY9SLUaUzB8=
github.com/go-openapi/swag/netutils v0.29.2/go.mod h1:1pM6Xg/Um8E1eps3umWl8Hme3ByWXOtHsvFC0CULHUE=
github.com/go-openapi/swag/pools v0.29.2 h1:PlGoDRF8WtSyXkedZZkpW3f9wDhFf3u8KtaBcmgmh8c=
github.com/go-openapi/swag/pools v0.29.2/go.mod h1:V3lhxVT4qDYRqc2MRSSbLsTYIYV/2HlHafhyEkmzLIc=
github.com/go-openapi/swag/stringutils v0.29.2 h1:lcnBxwAaysT3bMUVBfn9V/PkfVqkurpqufKU6rTUMGk=
github.com/go-openapi/swag/stringutils v0.29.2/go.mod h1:i9cdh7sGaa0nm3CqIvH5IM5h2QClk1wns2ktKeILvRI=
github.com/go-openapi/swag/typeutils v0.29.2 h1:O7aVvkTs3pXwikgtrPLenigEMdQFgONKRooQA9pgf2I=
github.com/go-openapi/swag/typeutils v0.29.2/go.mod h1:7+GDG+uz9Ke+eVt4rClZg7wklSDp8hT/mKNh/pC26TE=
github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAYjV/RlnlxPLM=
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/go-openapi/validate v1.0.0 h1:dFsYCLVUQUL6Vi2lQSexgwmCXDuHe7eWRDhQxkE+xYA=
github.com/go-openapi/validate v1.0.0/go.mod h1:wwXGRqMQzOZ7PCqBcgNk+DD9+Cacnxv7we5T0M/eA3Y=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
@@ -404,8 +402,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-test/deep v1.1.2 h1:JUuyv17rKrqYfLFnP9A4Yh6hw0635ugB93bMg2bwuDQ=
github.com/go-test/deep v1.1.2/go.mod h1:lwVFiFFWPjydTsV3xGJ/YTQsnWwi8zxBFM+gOVcwINE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -417,8 +415,8 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo=
github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE=
github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
@@ -445,8 +443,8 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -462,22 +460,22 @@ github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJ
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.19 h1:mMOE7DN2+p76/EdIrmAy9B9bH+yC4563vmnJ34QR8i4=
github.com/googleapis/enterprise-certificate-proxy v0.3.19/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.23.0 h1:Tchl7qkvE7Ip3y+ztvNufYFvkfqTe7NfLTYGIdJRLuE=
github.com/googleapis/gax-go/v2 v2.23.0/go.mod h1:rBQKOVJCdb8IFEzg+FCwlt1LP/xMDGuqUXhUG+XMXEg=
github.com/googleapis/enterprise-certificate-proxy v0.3.20 h1:t/xL64VUoN69MuMRQuJETqYGOw4Z9mSRJK9epIEtwFk=
github.com/googleapis/enterprise-certificate-proxy v0.3.20/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg=
github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU=
github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/gookit/color v1.6.3 h1:m6E49x+KftGaspcLtPLMsyci2ARhWIUW0PUV1owMlhs=
github.com/gookit/color v1.6.3/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
@@ -488,8 +486,8 @@ github.com/goware/prefixer v0.0.0-20160118172347-395022866408 h1:Y9iQJfEqnN3/Nce
github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukgRPJ7bJ9a1fdfQ9j8i/cEcRAoLZzbxYpNB/s=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -497,8 +495,8 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-cty-funcs v0.1.0 h1:TRO/6x1unvTPpotTgrTU7qlcbd99JBLt+vmF6dMF6lY=
github.com/hashicorp/go-cty-funcs v0.1.0/go.mod h1:crc3afXAsjGOJ+12LNX8PImH+ejyxOjnjvsUteKcFIw=
github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk=
github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY=
github.com/hashicorp/go-getter/v2 v2.2.5 h1:kfj7TeF3Mpq6UZcZxXfz7sgig85sTmPYwfGHd8UfTII=
github.com/hashicorp/go-getter/v2 v2.2.5/go.mod h1:aZFhc+xmYUqWNxvBhlVi5ZJinWvSjelAFIeCDE5uIkM=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
@@ -531,18 +529,18 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQxvE=
github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM=
github.com/hashicorp/hcp-sdk-go v0.174.0 h1:BVUBgq4ZX5U5LeSb3OZilLUOFOlDqm1lfdFQDui8iPI=
github.com/hashicorp/hcp-sdk-go v0.174.0/go.mod h1:v2vbpNIrmgUTelW4Z+ur+aQuSPxeaVK3xytFdpEXvSg=
github.com/hashicorp/hcl/v2 v2.25.0 h1:HmmQVYRny4MaBo4b20TjmL46wyuUxpnMWkPZ4+NTbWk=
github.com/hashicorp/hcl/v2 v2.25.0/go.mod h1:vR+FKETxoZAmRlHgFfKmuqivj+C4Izm/c66XkmZ3r7M=
github.com/hashicorp/hcp-sdk-go v0.175.0 h1:Ff9CVHDzk74qNDS+grGcwmS8HP9xSAvwFK76L3fuQVE=
github.com/hashicorp/hcp-sdk-go v0.175.0/go.mod h1:v2vbpNIrmgUTelW4Z+ur+aQuSPxeaVK3xytFdpEXvSg=
github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e h1:xwy/1T0cxHWaLx2MM0g4BlaQc1BXn/9835mPrBqwSPU=
github.com/hashicorp/jsonapi v1.4.3-0.20250220162346-81a76b606f3e/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM=
github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A=
github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko=
github.com/helmfile/chartify v0.28.2 h1:Xi+KOAoPlAP0WraYPcMfJE9HYx9Pf01TRWL5kQFSDeM=
github.com/helmfile/chartify v0.28.2/go.mod h1:ATb9N7qzJMiJG6ZJMSAhDv6X0JEi/2SaBJG8pMksSCg=
github.com/helmfile/vals v0.46.0 h1:cxXqsKAtk1shtHX3tmmXxjlvAw8lN0l9zaHbbU7JBcw=
github.com/helmfile/vals v0.46.0/go.mod h1:ZRDi8e0oF1snqzaAOQ9AA8n8+pEJmJtc9jSV7LfzDxI=
github.com/helmfile/chartify v0.29.0 h1:IfE5XqDYwW0FVnVBIt3YDwlSkYBEAuJEcbFG99wwNAg=
github.com/helmfile/chartify v0.29.0/go.mod h1:z5icqRatx1gRVuMbv0qT3e9jMoDx3BKXiw6RXIaT2vs=
github.com/helmfile/vals v0.47.0 h1:K6ef8t9NEvQdSDsgLb9r++1NK2MezONMWyBINQzwQXM=
github.com/helmfile/vals v0.47.0/go.mod h1:VDhFfy8XplCvEeiR9hN6JCjpumtuldUs+XnGkfF9nQs=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f h1:7LYC+Yfkj3CTRcShK0KOL/w6iTiKyqqBA9a41Wnggw8=
@@ -551,8 +549,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.202 h1:GLG7UGUNWcZ65fFYFwi/tbC7IdTG4JAKwc9H+8H5VzE=
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.202/go.mod h1:M+yna96Fx9o5GbIUnF3OvVvQGjgfVSyeJbV9Yb1z/wI=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw=
github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE=
github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/infisical/go-sdk v0.8.0 h1:Ipw62V1ptg77OY/5DYH/39D2Ej61Hnrbgn05PCZd32c=
@@ -572,8 +570,8 @@ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:C
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -598,11 +596,11 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU=
github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-runewidth v0.0.31 h1:1oCV4ReTlosDBc6rYRw5cL8S/4y8iv5oRl4SJ5IBgwo=
github.com/mattn/go-runewidth v0.0.31/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@@ -647,14 +645,14 @@ github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs
github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
github.com/openbao/openbao/api/v2 v2.6.0 h1:KvfspAaL9bab9hI8jFYkV2cgtSrwWtaG+k9AUTHWU4M=
github.com/openbao/openbao/api/v2 v2.6.0/go.mod h1:H4IWiH+2rgF/TbrsUbsfrMyGoqojkLqxPCRLENSMnSo=
github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec=
github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E=
github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I=
github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/openbao/openbao/api/v2 v2.7.0 h1:3CD1l3tr39nQraCgFGAWA5vYvPFzZoZrt3NL7DMQKAc=
github.com/openbao/openbao/api/v2 v2.7.0/go.mod h1:uXbMoyH2pjSvNyTepinUvLde8pOJB82EuhUCfOKnKbo=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -674,8 +672,6 @@ github.com/otiai10/mint v1.6.3/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAq
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -687,17 +683,17 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY=
github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo=
github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM=
github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4=
github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics=
github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58=
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 h1:EaDatTxkdHG+U3Bk4EUr+DZ7fOGwTfezUiUJMaIcaho=
github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5/go.mod h1:fyalQWdtzDBECAQFBJuQe5bzQ02jGd5Qcbgb97Flm7U=
github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 h1:EfpWLLCyXw8PSM2/XNJLjI3Pb27yVE+gIAfeqp8LUCc=
@@ -705,8 +701,8 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.0.5/go.mod h1:WZjPDy7VNzn77AAfnA
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g=
github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
@@ -718,19 +714,19 @@ github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkB
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sashabaranov/go-openai v1.42.0 h1:fgeZx7/D8dRT//PwXAGe9ylOMtj6vrs999uWF71K+f8=
github.com/sashabaranov/go-openai v1.42.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36 h1:ObX9hZmK+VmijreZO/8x9pQ8/P/ToHD/bdSb4Eg4tUo=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36/go.mod h1:LEsDu4BubxK7/cWhtlQWfuxwL4rf/2UEpxXz1o1EMtM=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sashabaranov/go-openai v1.43.0 h1:HNRpO8TAQ01ssO7aPXO/68QRlcCCYQQ5GfHbFceRZcY=
github.com/sashabaranov/go-openai v1.43.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37 h1:1Q6K8D0BagYYEnCTkT9fn3YHUFb06bS1OvIHWcc3JQM=
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.37/go.mod h1:Rtb4r3WZ5x4AqmL3t/wiF/DmQi+7GlU/nCRdqFbClV4=
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/sony/gobreaker v0.5.0 h1:dRCvqm0P490vZPmy7ppEk2qCnCieBooFJ+YoXGYB+yg=
@@ -809,10 +805,10 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yandex-cloud/go-genproto v0.95.0 h1:ByHofu3SFftRGDGNFCLOP8a+nAPJ0vF9qVtTKWiUVIQ=
github.com/yandex-cloud/go-genproto v0.95.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
github.com/yandex-cloud/go-sdk v0.32.0 h1:+krs7xf7rPvuPcRITAnqovrKraj5S62VconSPjmAOo4=
github.com/yandex-cloud/go-sdk v0.32.0/go.mod h1:YIEb0cQHowyOaIy7BAPUu2oejnoiu1q8z5Yk0KH+pxo=
github.com/yandex-cloud/go-genproto v0.121.0 h1:OigYLrjvm/a8V+mQC35rn264BLhw4nV71WZ/4mITH84=
github.com/yandex-cloud/go-genproto v0.121.0/go.mod h1:0LDD/IZLIUIV4iPH+YcF+jysO3jkSvADFGm4dCAuwQo=
github.com/yandex-cloud/go-sdk v0.35.0 h1:vuvt/b0Wicu5UK84/e5dzDEEFBri1yh7fW8ROkiXwog=
github.com/yandex-cloud/go-sdk v0.35.0/go.mod h1:rSkifg+iUBWUf86kg1zKy6iF3QbNx3mOVjJ3SbfPR/s=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
@@ -832,56 +828,58 @@ go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/bridges/prometheus v0.72.0 h1:OUIUu7ovHCEvG4IE5LQd2PNBH2eVYh50w/1ROwHEM7M=
go.opentelemetry.io/contrib/bridges/prometheus v0.72.0/go.mod h1:pCrnkC07AZ5+h+0A0Z/1GYyqB+xu3A8LaEEhxvv9s6o=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/contrib/exporters/autoexport v0.72.0 h1:TPXrl7vEkmh69tp7dHTpz+Y6AUenFg1Du3Nx3a6dlxY=
go.opentelemetry.io/contrib/exporters/autoexport v0.72.0/go.mod h1:0jE6K8nsOgeiF53rcEhy71cgakek+XEWi4OPROUqLoI=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM=
go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0 h1:5Ek4qQ5XX0PSQfY7lMW3CvcR36yy8YFDfGnYnaku8zE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.23.0/go.mod h1:NUKIMOKSexQdTmS1pSxOQBDiVes3DHRier2ePsGW/gQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0 h1:5WVp40k2fJHuc5BtNTgKk+EugKFT8Eg3lywFfCPI/nE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.23.0/go.mod h1:59nH0RYCGHRlwt15WyioeSYINBbuObeBpTowPhEKWHY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0 h1:LaLVGJtIzHcvHjVs2OftmKcc+M9mdOqS8vZ1fBD1Yes=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.47.0/go.mod h1:vF6H5sNDuQBJWgSX00iZgbIqk2MZZoVMH0ar5t5bOpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0 h1:7kszRN4Ze5GeAkUnBjBSUIunVBMA+mZIG04auueqguM=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.47.0/go.mod h1:CLzakYy8jOdkBfcveCWYkeKJxujcF4xPhh2Z7IfE98U=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38=
go.opentelemetry.io/otel/exporters/prometheus v0.69.0 h1:LQVBTGnHvzrfGJpDoCSVTY7eaLZArvaTSq5l84Skxew=
go.opentelemetry.io/otel/exporters/prometheus v0.69.0/go.mod h1:moif0nlgj/M16NRT8lDZVt8MC52+EOAJfR8EKRJ8LyU=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0 h1:PW/E9Tw6ZykC88pey0MZY8hKOUeEw+hX6PU+oXA4A9o=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.23.0/go.mod h1:hUw4VNfiOnQ/yzGgy6ZdWVnudXw7iRfImhd6FjXOnvI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0 h1:ww5DYBpMKcqLdxjWrhGUIh6O+ZfvZUaMk4niVyNQPEQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.47.0/go.mod h1:Av5t+Ezx5S/Ks2dsEfWhny+GK+wrZ1K7NZ6aHSWnX3U=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA=
go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI=
go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw=
go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY=
go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU=
go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM=
go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k=
go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I=
go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0=
go.opentelemetry.io/otel/sdk/log v1.47.0 h1:W4N/ZfgpkRRnmARnqYoQ6BrWA0mrj8IzVDNLlpmUJZY=
go.opentelemetry.io/otel/sdk/log v1.47.0/go.mod h1:Wu74Z2EmOKXi2FleSNAb0Hyau2G+r5kubSEMJw5aAMs=
go.opentelemetry.io/otel/sdk/log/logtest v0.23.0 h1:j3pm42QCrKYYLLkI6OrV1QHFkUfDA6/4EP+Y1PzggLg=
go.opentelemetry.io/otel/sdk/log/logtest v0.23.0/go.mod h1:zt0gEnuTZgDvqvczHwYswz93eqMaIr6upTTsdV/HUlU=
go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw=
go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw=
go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM=
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/proto/otlp v1.11.1 h1:SCrPqH9NE5CmBrOqpjNCiT0X5mGwMyGERXdogfR7Yjw=
go.opentelemetry.io/proto/otlp v1.11.1/go.mod h1:wq8W4aSf6bOy8RZyESOeQFr9Pu7wydWX/TY4sgvrTPg=
go.szostok.io/version v1.2.0 h1:8eMMdfsonjbibwZRLJ8TnrErY8bThFTQsZYV16mcXms=
go.szostok.io/version v1.2.0/go.mod h1:EiU0gPxaXb6MZ+apSN0WgDO6F4JXyC99k9PIXf2k2E8=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
@@ -910,19 +908,19 @@ golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -939,19 +937,19 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -977,8 +975,8 @@ golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -988,8 +986,8 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -1000,8 +998,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
@@ -1015,40 +1013,40 @@ golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.291.0 h1:wfPbbY+mr9c7wZLqqzrHJLft/q8iFKREd6IgTBUene0=
google.golang.org/api v0.291.0/go.mod h1:at7kwWbuonglBFEBoeMDAV1bguHqL3qf0BHFsv3coa0=
google.golang.org/api v0.296.0 h1:Nn5EHeKdGx70MFClaV/II0gsWUm6xhEjb0xYLylVvaA=
google.golang.org/api v0.296.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df h1:O3ig1i5WDDzsVzRp+cCdgelT9vXnlnOFdlEeFtL4HCc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260724162435-b2f20204f0df/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms=
google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0=
google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -1068,34 +1066,34 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
helm.sh/helm/v3 v3.21.4 h1:T/GcIEXU/gNjJnkITlIZ3e9xqkZjhFTmISuStTZ6+Qg=
helm.sh/helm/v3 v3.21.4/go.mod h1:cS2FBb+xfLuaSqvEmbqIeKUVFgHdHVHtVeXb2epof3M=
helm.sh/helm/v4 v4.2.4 h1:qIysMI0JpTC4WXf3AQ99V6rZGT0+gO0Ww8IOnnUnaZk=
helm.sh/helm/v4 v4.2.4/go.mod h1:ZP8nFdYe7jG1PTQelKzQXQ7m09/ruhMTrpDAf+OL5ms=
helm.sh/helm/v3 v3.22.1 h1:uo2SouzAxMkKZTNzZ1lLP/c/Bu7xfUJKYDaYMxMHvk8=
helm.sh/helm/v3 v3.22.1/go.mod h1:FNQoawxnIIReGrTytJc55tKakylfpaC3pGf64BGo1rI=
helm.sh/helm/v4 v4.3.1 h1:UndCoLe9UPmNoL7IvWzaUXveL7T5t/ijoorEmgJdI9g=
helm.sh/helm/v4 v4.3.1/go.mod h1:Z663D/wNQG1UsG3X7svSEBPvosFVel4SIw4YIMZwmuY=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988=
k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU=
k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4=
k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA=
k8s.io/apiextensions-apiserver v0.37.1 h1:7fIQG8eThDSTVYBWg/DOpI8v5wYfKCo0N8/TDMjj+zY=
k8s.io/apiextensions-apiserver v0.37.1/go.mod h1:1Q3ujWwHgnhBoGsdmGORflwK1sDFmuaqH1OsMDWk2Gs=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E=
k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k=
k8s.io/cli-runtime v0.36.2 h1:CconTvEeV4DJs4ZX3HQKCFbFRGsm6OtuBM9yjmMP2VM=
k8s.io/cli-runtime v0.36.2/go.mod h1:LddcjiMf4YlnHO7c1Y7rEtDqL84FyiYVLco7V679GUU=
k8s.io/apiserver v0.37.1 h1:dUsNwjsvItyVKNpC1N0AhQ0OBSiUJKfWz004vPifOe0=
k8s.io/apiserver v0.37.1/go.mod h1:EWXW6PntGfOC2uD9GGVobnOExgFoX1z0hZo7f7Rs7Ng=
k8s.io/cli-runtime v0.37.1 h1:3mir5bM4XjMJHW3SMRk++3Ylf4YQne0XFFsW+IGT85U=
k8s.io/cli-runtime v0.37.1/go.mod h1:g3VQOm71f//aNbp79g0az9jBRTrvPjAHz7WxVysbZ4M=
k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8=
k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY=
k8s.io/component-base v0.36.2 h1:Z0VH80O7Ng0HDZnZj3WRR3urEGa0kTwmO8CwEwjVK1w=
k8s.io/component-base v0.36.2/go.mod h1:mGfFOA7Gwpdm1VW2cwSQYbiDIlz8GD2WGwH88QSeCyA=
k8s.io/component-base v0.37.1 h1:93DMmlENnK7gNLkL4pMqLO5M/HVf3p3sM4q/GasLveY=
k8s.io/component-base v0.37.1/go.mod h1:bBrdziT4dreQG5lzTaPVxBwsZe1oxphG+ZVdD56dQWQ=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/kubectl v0.36.2 h1:rpUGGpeL09XVOLep2yle5jrtk//JA1L6ZHfkQQtVEwk=
k8s.io/kubectl v0.36.2/go.mod h1:gVbQ3B/yb4bSR2ggQ7rd0W6icUSWs7sduH4e16Vii+0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/kubectl v0.37.1 h1:n1VCIntOJiX943uByXCThSZQmE+4v9jGS8S9u94/aHU=
k8s.io/kubectl v0.37.1/go.mod h1:66cc4Bz8PxBTxlrHzBuzXEzGIytAnNasKQPeEcpuaV8=
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
@@ -1104,11 +1102,11 @@ sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5E
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs=
sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI=
sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI=
sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ=
sigs.k8s.io/kustomize/kyaml v0.21.2 h1:1javwStFk7cgOeLU7yJtPmXcgMEhQgC2X0WjFT6U0p0=
sigs.k8s.io/kustomize/kyaml v0.21.2/go.mod h1:zX3qwtuouXd2K1fMiCV0VSFReX06a+CY1rhyf5Dy7hQ=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.3 h1:u08YRbVUi59ri4YD6cg0UqNM4Dimn0sIl+wldcx5PYw=
sigs.k8s.io/structured-merge-diff/v6 v6.3.3/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
+39
View File
@@ -1,6 +1,7 @@
package main
import (
gocontext "context"
"os"
"os/signal"
"syscall"
@@ -9,6 +10,7 @@ import (
"github.com/helmfile/helmfile/pkg/app"
"github.com/helmfile/helmfile/pkg/config"
"github.com/helmfile/helmfile/pkg/errors"
"github.com/helmfile/helmfile/pkg/telemetry"
)
func main() {
@@ -35,6 +37,8 @@ func main() {
app.Cancel()
app.CleanWaitGroup.Wait()
shutdownTelemetry(nil, signalExitCode(sig))
// See http://tldp.org/LDP/abs/html/exitcodes.html
switch sig {
case syscall.SIGINT:
@@ -44,6 +48,41 @@ func main() {
}
}
case err := <-errChan:
shutdownTelemetry(err, exitCodeOf(err))
errors.HandleExitCoder(err)
}
}
// shutdownTelemetry flushes buffered spans before the process exits. It is a
// no-op when tracing was never enabled. Flush failures are deliberately
// ignored: telemetry must not mask the command's own result.
func shutdownTelemetry(runErr error, exitCode int) {
ctx, cancel := gocontext.WithTimeout(gocontext.Background(), telemetry.ShutdownTimeout)
defer cancel()
_ = telemetry.Shutdown(ctx, runErr, exitCode)
}
// signalExitCode maps termination signals to conventional exit codes, matching
// the os.Exit calls in main.
func signalExitCode(sig os.Signal) int {
switch sig {
case syscall.SIGINT:
return 130
case syscall.SIGTERM:
return 143
default:
return 1
}
}
// exitCodeOf extracts the exit code from the command result for the root-span
// attribute; success is 0.
func exitCodeOf(err error) int {
if err == nil {
return 0
}
if exitErr, ok := err.(errors.ExitCoder); ok {
return exitErr.ExitCode()
}
return 1
}
+1
View File
@@ -32,6 +32,7 @@ nav:
- Advanced Features: advanced-features.md
- Hooks: hooks.md
- Secrets: remote-secrets.md
- OpenTelemetry Tracing: otel.md
- Shared Configuration: shared-configuration-across-teams.md
- Integrations: integrations.md
- Experimental Features: experimental-features.md
+1 -1
View File
@@ -224,7 +224,7 @@ func TestReportJSON_FieldOrderIsHumanReadable(t *testing.T) {
// Cheap way: scan line-by-line, the first 7 non-space chars of each line
// are `"key":`.
keys := []string{}
for _, line := range strings.Split(out, "\n") {
for line := range strings.SplitSeq(out, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, `"`) {
continue
+6 -3
View File
@@ -50,7 +50,10 @@ func (o *openaiClient) Analyze(ctx goContext.Context, diff string, extras Analyz
req := openai.ChatCompletionRequest{
Model: o.cfg.Model,
Temperature: o.cfg.Temperature,
MaxTokens: o.cfg.MaxTokens,
// SA1019: keep the deprecated MaxTokens field deliberately: `max_tokens` is the
// only form universally accepted by OpenAI-compatible backends (One-API,
// LiteLLM, Ollama shim); MaxCompletionTokens is not supported by all of them.
MaxTokens: o.cfg.MaxTokens, //nolint:staticcheck // SA1019
ResponseFormat: &openai.ChatCompletionResponseFormat{
Type: openai.ChatCompletionResponseFormatTypeJSONObject,
},
@@ -166,8 +169,8 @@ func stripJSONCodeFence(s string) string {
// Case 2: fenced JSON.
if strings.HasPrefix(s, "```") {
rest := s[3:]
if nl := strings.IndexByte(rest, '\n'); nl >= 0 {
s = rest[nl+1:]
if _, after, ok := strings.Cut(rest, "\n"); ok {
s = after
} else {
s = rest
}
+1 -1
View File
@@ -141,7 +141,7 @@ func TestMockClient_Analyze_PropagatesErr(t *testing.T) {
func TestMockClient_ConcurrentSafe(t *testing.T) {
c := NewMockClient(Analysis{Summary: "ok"})
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
for range 20 {
wg.Add(2)
go func() {
defer wg.Done()
+252 -53
View File
@@ -3,15 +3,19 @@ package app
import (
"bytes"
goContext "context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"text/tabwriter"
"github.com/helmfile/vals"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/argparser"
@@ -22,6 +26,7 @@ import (
"github.com/helmfile/helmfile/pkg/plugins"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/state"
"github.com/helmfile/helmfile/pkg/telemetry"
)
var CleanWaitGroup sync.WaitGroup
@@ -62,6 +67,9 @@ type App struct {
helms map[helmKey]helmexec.Interface
helmsMutex sync.Mutex
rootHelmfileDir string
rootHelmfileDirOnce sync.Once
ctx goContext.Context
}
@@ -76,7 +84,11 @@ type HelmRelease struct {
}
func New(conf ConfigProvider) *App {
ctx := goContext.Background()
// telemetry.CommandContext returns context.Background when tracing is
// disabled, so this is behavior-identical to the previous explicit
// Background() while rooting the app context under the command span when
// tracing is on.
ctx := telemetry.CommandContext()
ctx, Cancel = goContext.WithCancel(ctx)
return Init(&App{
@@ -173,12 +185,14 @@ func (a *App) Diff(c DiffConfigProvider) error {
prepErr := run.WithPreparedCharts("diff", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
IncludeCRDs: &includeCRDs,
Validate: c.Validate(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeNeeds(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
PrefetchSharedRemoteCharts: true,
}, func() []error {
msg, matched, affected, errs = a.diff(run, c)
@@ -246,6 +260,7 @@ func (a *App) Template(c TemplateConfigProvider) error {
prepErr := run.WithPreparedCharts("template", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
IncludeCRDs: &includeCRDs,
@@ -274,6 +289,8 @@ func (a *App) Template(c TemplateConfigProvider) error {
func (a *App) WriteValues(c WriteValuesConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
prepErr := run.WithPreparedCharts("write-values", state.ChartPrepareOptions{
// Note: "write-values" never prepares charts (see commandsSkipChartPrep
// in run.go), so AllowFailedReleases does not apply here.
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
@@ -329,10 +346,12 @@ func (a *App) Lint(c LintConfigProvider) error {
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipCleanup: c.SkipCleanup(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeNeeds(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
}, func() []error {
ok, lintErrs, errs = a.lint(run, c)
return append(errs, lintErrs...)
@@ -371,10 +390,12 @@ func (a *App) Unittest(c UnittestConfigProvider) error {
ForceDownload: true,
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipCleanup: c.SkipCleanup(),
Concurrency: c.Concurrency(),
IncludeTransitiveNeeds: c.IncludeTransitiveNeeds(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
}, func() []error {
ok, unittestErrs, errs = a.unittest(run, c)
return append(errs, unittestErrs...)
@@ -447,13 +468,15 @@ func (a *App) Fetch(c FetchConfigProvider) error {
}
prepErr := run.WithPreparedCharts("pull", state.ChartPrepareOptions{
ForceDownload: true,
SkipRefresh: c.SkipRefresh(),
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipDeps: c.SkipDeps(),
OutputDir: c.OutputDir(),
OutputDirTemplate: c.OutputDirTemplate(),
Concurrency: c.Concurrency(),
ForceDownload: true,
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipDeps: c.SkipDeps(),
OutputDir: c.OutputDir(),
OutputDirTemplate: c.OutputDirTemplate(),
Concurrency: c.Concurrency(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
}, func() []error {
if c.WriteOutput() {
for i := range run.state.Releases {
@@ -504,6 +527,7 @@ func (a *App) Sync(c SyncConfigProvider) error {
prepErr := run.WithPreparedCharts("sync", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
Wait: c.Wait(),
@@ -513,6 +537,7 @@ func (a *App) Sync(c SyncConfigProvider) error {
IncludeTransitiveNeeds: c.IncludeNeeds(),
Validate: c.Validate(),
Concurrency: c.Concurrency(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
TemplateArgs: c.TemplateArgs(),
PrefetchSharedRemoteCharts: true,
}, func() []error {
@@ -562,6 +587,7 @@ func (a *App) Apply(c ApplyConfigProvider) error {
prepErr := run.WithPreparedCharts("apply", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
SkipSchemaValidation: c.SkipSchemaValidation(),
Wait: c.Wait(),
@@ -571,6 +597,7 @@ func (a *App) Apply(c ApplyConfigProvider) error {
SkipCleanup: c.SkipCleanup(),
Validate: c.Validate(),
Concurrency: c.Concurrency(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
IncludeTransitiveNeeds: c.IncludeNeeds(),
TemplateArgs: c.TemplateArgs(),
PrefetchSharedRemoteCharts: true,
@@ -608,9 +635,10 @@ func (a *App) Apply(c ApplyConfigProvider) error {
func (a *App) Status(c StatusesConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
err := run.WithPreparedCharts("status", state.ChartPrepareOptions{
SkipRepos: true,
SkipDeps: true,
Concurrency: c.Concurrency(),
SkipRepos: true,
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: true,
Concurrency: c.Concurrency(),
}, func() []error {
ok, errs = a.status(run, c)
return errs
@@ -628,12 +656,14 @@ func (a *App) Destroy(c DestroyConfigProvider) error {
return a.ForEachState(func(run *Run) (ok bool, errs []error) {
if !c.SkipCharts() {
err := run.WithPreparedCharts("destroy", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
Concurrency: c.Concurrency(),
DeleteWait: c.DeleteWait(),
DeleteTimeout: c.DeleteTimeout(),
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
Concurrency: c.Concurrency(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
DeleteWait: c.DeleteWait(),
DeleteTimeout: c.DeleteTimeout(),
}, func() []error {
ok, errs = a.delete(run, true, c)
return errs
@@ -657,10 +687,12 @@ func (a *App) Test(c TestConfigProvider) error {
}
err := run.WithPreparedCharts("test", state.ChartPrepareOptions{
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
SkipDeps: c.SkipDeps(),
Concurrency: c.Concurrency(),
SkipRepos: c.SkipRefresh() || c.SkipDeps(),
SkipRefresh: c.SkipRefresh(),
AllowFailedReleases: c.AllowFailedReleases(),
SkipDeps: c.SkipDeps(),
Concurrency: c.Concurrency(),
HelmOCIPlainHTTP: a.HelmOCIPlainHTTP,
}, func() []error {
errs = a.test(run, c)
return errs
@@ -760,7 +792,14 @@ func (a *App) dag(r *Run) error {
}
func (a *App) ListReleases(c ListConfigProvider) error {
releasesChan := make(chan []*HelmRelease, 100)
// Collect the per-state results under a mutex. ForEachState may visit
// states concurrently, and a bounded channel that is drained only after the
// visit finishes blocks forever once more states carry releases than the
// buffer can hold (the previous 100-slot channel deadlocked at 101 states).
var (
releasesMu sync.Mutex
releases []*HelmRelease
)
err := a.ForEachState(func(run *Run) (_ bool, errs []error) {
var stateReleases []*HelmRelease
@@ -768,6 +807,8 @@ func (a *App) ListReleases(c ListConfigProvider) error {
if !c.SkipCharts() {
prepErr := run.WithPreparedCharts("list", state.ChartPrepareOptions{
// Note: "list" never prepares charts (see commandsSkipChartPrep in
// run.go), so AllowFailedReleases does not apply here.
SkipRepos: true,
SkipDeps: true,
Concurrency: 2,
@@ -791,20 +832,14 @@ func (a *App) ListReleases(c ListConfigProvider) error {
}
if len(stateReleases) > 0 {
releasesChan <- stateReleases
releasesMu.Lock()
releases = append(releases, stateReleases...)
releasesMu.Unlock()
}
return
}, false, SetFilter(true))
close(releasesChan)
// Collect all releases from channel
var releases []*HelmRelease
for rels := range releasesChan {
releases = append(releases, rels...)
}
if err != nil {
return err
}
@@ -921,6 +956,13 @@ func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, o
op = opts[0]
}
// The load span covers remote fetching, rendering, and parsing of one
// state file; render/parse spans attach through the loader's traceCtx.
loadCtx, loadSpan := telemetry.Tracer(telemetry.ScopeHelmfile).Start(a.spanParentCtx(), "helmfile.load",
trace.WithAttributes(attribute.String("helmfile.state_file", file)),
)
defer loadSpan.End()
ld := &desiredStateLoader{
fs: a.fs,
env: a.Env,
@@ -929,6 +971,7 @@ func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, o
logger: a.Logger,
remote: a.remote,
baseDir: baseDir,
traceCtx: loadCtx,
overrideKubeContext: a.OverrideKubeContext,
overrideHelmBinary: a.OverrideHelmBinary,
@@ -943,6 +986,11 @@ func (a *App) loadDesiredStateFromYamlWithBaseDir(file string, baseDir string, o
return nil, err
}
// Per-release spans (pkg/state) parent under the load span.
st.SetTraceContext(loadCtx)
// Kubedog tracking / buffered helm subprocesses cancel with the app.
st.SetCancelContext(a.ctx)
st.SetKubeconfig(a.Kubeconfig)
return st, nil
@@ -1050,6 +1098,7 @@ func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converg
}
st.Selectors = opts.Selectors
st.SetRootDir(a.RootHelmfileDir())
// Track whether any releases matched across nested helmfiles and converge.
// Aggregate into a single send to matchChan to avoid overfilling the buffer
@@ -1084,7 +1133,7 @@ func (a *App) processStateFileParallel(relPath string, defOpts LoadOpts, converg
processed, errs := converge(templated)
if len(errs) > 0 {
errChan <- errs[0]
errChan <- aggregateErrors(errs)
return
}
if cleanErr != nil {
@@ -1131,16 +1180,29 @@ func (a *App) processNestedHelmfiles(st *state.HelmState, absd, file string, def
continue
}
// Selectors that govern this sub-tree. Matches the inheritance model:
// CLI (parent) selectors flow down when the entry has none of its own
// (and explicit-selector-inheritance is off), or when SelectorsInherited
// is set; otherwise the entry's own selectors take over. Used both for
// the dir-skip decision below and for the nested LoadOpts.
var effectiveSelectors []string
if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
effectiveSelectors = opts.Selectors
} else {
effectiveSelectors = m.Selectors
}
if a.skipForDirFilter(absd, m.Path, effectiveSelectors) {
a.Logger.Debugf("skipping subhelmfile %q: outside dir= selector scope", m.Path)
continue
}
optsForNestedState := LoadOpts{
CalleePath: filepath.Join(absd, file),
Environment: m.Environment,
Reverse: defOpts.Reverse,
RetainValuesFiles: defOpts.RetainValuesFiles,
}
if (m.Selectors == nil && !isExplicitSelectorInheritanceEnabled()) || m.SelectorsInherited {
optsForNestedState.Selectors = opts.Selectors
} else {
optsForNestedState.Selectors = m.Selectors
Selectors: effectiveSelectors,
}
// Carry parent config requested via `inherits:`, and parent repo names
@@ -1290,6 +1352,7 @@ func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converg
}
st.Selectors = opts.Selectors
st.SetRootDir(a.RootHelmfileDir())
if !opts.Reverse && len(st.Helmfiles) > 0 {
matched, err := a.processNestedHelmfiles(st, absd, file, defOpts, opts, converge, sharedCtx)
@@ -1325,7 +1388,7 @@ func (a *App) visitStatesWithContext(fileOrDir string, defOpts LoadOpts, converg
processed, errs = converge(templated)
if len(errs) > 0 {
return errs[0]
return aggregateErrors(errs)
}
noMatchInHelmfiles = noMatchInHelmfiles && !processed
@@ -1474,18 +1537,16 @@ func withBatches(purpose string, templated *state.HelmState, batches [][]state.R
logger.Debugf("%s %d groups of releases in this order:\n%s", purpose, numBatches, printBatches(batches))
any := false
var allErrs []error
blockedByID := map[string]blockedRelease{}
for i, batch := range batches {
var targets []state.ReleaseSpec
for _, marked := range batch {
targets = append(targets, marked.ReleaseSpec)
}
targets, skippedErrs := filterBlockedReleases(batch, blockedByID, logger)
allErrs = append(allErrs, skippedErrs...)
var releaseIds []string
for _, r := range targets {
release := r
releaseIds = append(releaseIds, state.ReleaseToID(&release))
releaseIds = append(releaseIds, state.ReleaseToID(&r))
}
logger.Debugf("%s releases in group %d/%d: %s", purpose, i+1, numBatches, strings.Join(releaseIds, ", "))
@@ -1493,16 +1554,109 @@ func withBatches(purpose string, templated *state.HelmState, batches [][]state.R
batchSt := *templated
batchSt.Releases = targets
processed, errs := converge(&batchSt, helm)
processed, batchErrs := converge(&batchSt, helm)
allErrs = append(allErrs, batchErrs...)
if len(errs) > 0 {
return false, errs
if toleratesBatchErrors(batchErrs, blockedByID) {
any = any || processed
continue
}
any = any || processed
return false, allErrs
}
return any, nil
return any, allErrs
}
// blockedRelease remembers a release that failed or was skipped, so that its
// dependents can be skipped transitively and reported with its plain name
// (rather than the kubeContext/namespace-qualified `needs` id).
type blockedRelease struct {
release *state.ReleaseSpec
err error
}
// filterBlockedReleases partitions a batch into the releases that may still be
// processed and those that must be skipped because one of their `needs`
// dependencies already failed or was skipped. Skipped releases are recorded in
// blockedByID (so that their own dependents are skipped transitively) and
// reported as errors, so that a failed dependency never results in a
// successful command.
func filterBlockedReleases(batch []state.Release, blockedByID map[string]blockedRelease, logger *zap.SugaredLogger) ([]state.ReleaseSpec, []error) {
var targets []state.ReleaseSpec
var skippedErrs []error
for _, marked := range batch {
release := marked.ReleaseSpec
blocked, ok := firstBlockedDependency(&release, blockedByID)
if !ok {
targets = append(targets, release)
continue
}
skippedErr := fmt.Errorf("release %q was skipped because dependency %q failed: %w", release.Name, blocked.release.Name, blocked.err)
skippedErrs = append(skippedErrs, skippedErr)
blockedByID[state.ReleaseToID(&release)] = blockedRelease{release: &release, err: skippedErr}
logger.Warnf("release %q was skipped because dependency %q failed: %v", release.Name, blocked.release.Name, blocked.err)
}
return targets, skippedErrs
}
// toleratesBatchErrors inspects the errors reported while processing a batch.
// Each per-release failure is recorded in blockedByID so that its dependents
// are skipped in subsequent batches. It returns true when every error is a
// release failure whose release opted in to continueOnError, so that
// independent branches of the DAG keep being processed. Any other error (a
// non-release error, or a release failure without continueOnError) is fatal
// and preserves the historical fail-fast behavior.
func toleratesBatchErrors(batchErrs []error, blockedByID map[string]blockedRelease) bool {
for _, err := range batchErrs {
releaseErr := releaseErrorForContinueOnError(err)
if releaseErr == nil {
return false
}
blockedByID[state.ReleaseToID(releaseErr.ReleaseSpec)] = blockedRelease{release: releaseErr.ReleaseSpec, err: err}
if !continueOnErrorEnabled(releaseErr.ReleaseSpec) {
return false
}
}
return true
}
func continueOnErrorEnabled(release *state.ReleaseSpec) bool {
return release != nil && release.ContinueOnError != nil && *release.ContinueOnError
}
func firstBlockedDependency(release *state.ReleaseSpec, blockedByID map[string]blockedRelease) (blockedRelease, bool) {
for _, need := range release.Needs {
if blocked, ok := blockedByID[need]; ok {
return blocked, true
}
}
return blockedRelease{}, false
}
// releaseErrorForContinueOnError returns err as a *state.ReleaseError when it
// is a plain release failure. Non-release errors, and release errors carrying
// a non-failure code (e.g. the diff "changes detected" exit code 2), are not
// tolerable failures and must keep failing fast.
func releaseErrorForContinueOnError(err error) *state.ReleaseError {
var releaseErr *state.ReleaseError
if !errors.As(err, &releaseErr) {
return nil
}
if releaseErr.Code != state.ReleaseErrorCodeFailure {
return nil
}
return releaseErr
}
type Opts struct {
@@ -1510,6 +1664,11 @@ type Opts struct {
}
func (a *App) visitStatesWithSelectorsAndRemoteSupportWithContext(fileOrDir string, converge func(*state.HelmState) (bool, []error), includeTransitiveNeeds bool, sharedCtx *Context, opt ...LoadOption) error {
// Resolve the root helmfile directory anchor for dir= filtering before
// any chdir or goroutine fan-out happens further down; it uses the
// process CWD at command start, which is the user's intent.
a.resolveRootHelmfileDir()
opts := LoadOpts{
Selectors: a.Selectors,
}
@@ -1625,7 +1784,24 @@ func (a *App) WrapWithoutSelector(converge func(*state.HelmState, helmexec.Inter
}
}
// spanParentCtx returns the context app-layer spans attach to. Tests
// construct App literals without a context, so nil falls back to Background
// (with tracing disabled, span starts are no-ops anyway).
func (a *App) spanParentCtx() goContext.Context {
if a.ctx != nil {
return a.ctx
}
return goContext.Background()
}
func (a *App) findDesiredStateFiles(specifiedPath string, opts LoadOpts) ([]string, error) {
_, span := telemetry.Tracer(telemetry.ScopeHelmfile).Start(a.spanParentCtx(), "helmfile.discover_states",
// specifiedPath is captured before Remote.Locate resolves it; it may
// be a remote reference carrying credentials in userinfo or query.
trace.WithAttributes(attribute.String("helmfile.path", helmexec.RedactedRef(specifiedPath))),
)
defer span.End()
path, err := a.remote.Locate(specifiedPath, "states")
if err != nil {
return nil, fmt.Errorf("locate: %v", err)
@@ -1692,9 +1868,7 @@ func (a *App) findDesiredStateFiles(specifiedPath string, opts LoadOpts) ([]stri
return files[j] < files[i]
})
} else {
sort.Slice(files, func(i, j int) bool {
return files[i] < files[j]
})
slices.Sort(files)
}
a.Logger.Debugf("found %d helmfile state files in %s: %s", len(ymlFiles)+len(gotmplFiles), helmfileDir, strings.Join(files, ", "))
@@ -1981,7 +2155,9 @@ Do you really want to apply?
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
TrackLogs: c.TrackLogs(),
TrackLogsUntilReady: c.TrackLogsUntilReady(),
TrackFailedLogs: c.TrackFailedLogs(),
TrackLogsInterval: c.TrackLogsInterval(),
HelmStuckGrace: c.HelmStuckGrace(),
TrackFailOnError: c.TrackFailOnError(),
Description: c.Description(),
@@ -2478,7 +2654,9 @@ Do you really want to sync?
TrackMode: c.TrackMode(),
TrackTimeout: c.TrackTimeout(),
TrackLogs: c.TrackLogs(),
TrackLogsUntilReady: c.TrackLogsUntilReady(),
TrackFailedLogs: c.TrackFailedLogs(),
TrackLogsInterval: c.TrackLogsInterval(),
HelmStuckGrace: c.HelmStuckGrace(),
TrackFailOnError: c.TrackFailOnError(),
Description: c.Description(),
@@ -2786,6 +2964,27 @@ func appError(msg string, err error) *Error {
return &Error{msg: msg, Errors: []error{err}}
}
// aggregateErrors combines multiple errors from processing a state file into a
// single *Error that renders all of them. Rendering a single error is
// identical to returning it directly, so aggregating does not change the
// reported message for the single-error case — but it keeps errors that are
// not the first one visible, which matters when continueOnError lets multiple
// releases fail or be skipped within one run.
func aggregateErrors(errs []error) error {
filtered := make([]error, 0, len(errs))
for _, err := range errs {
if err != nil {
filtered = append(filtered, err)
}
}
if len(filtered) == 0 {
return nil
}
return &Error{Errors: filtered}
}
func (c context) clean(errs []error) error {
if errs == nil {
errs = []error{}
+76
View File
@@ -3,8 +3,10 @@ package app
import (
"bytes"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/helmfile/vals"
@@ -303,6 +305,80 @@ func TestListWithJSONOutput(t *testing.T) {
})
}
// TestListWithManyStates guards against a deadlock in ListReleases: per-state
// results used to be collected through a channel with a fixed buffer of 100
// that was drained only after every state had been visited, so the 101st state
// carrying releases blocked forever and `helmfile list` hung without output.
func TestListWithManyStates(t *testing.T) {
const stateCount = 101
files := map[string]string{}
for i := 1; i <= stateCount; i++ {
files[fmt.Sprintf("/path/to/helmfile.d/helmfile_%03d.yaml", i)] = fmt.Sprintf(`
releases:
- name: release-%03d
namespace: default
chart: incubator/raw
`, i)
}
stdout := os.Stdout
defer func() { os.Stdout = stdout }()
var buffer bytes.Buffer
syncWriter := testhelper.NewSyncWriter(&buffer)
logger := helmexec.NewLogger(syncWriter, "debug")
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
if err != nil {
t.Fatalf("unexpected error creating vals runtime: %v", err)
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
valsRuntime: valsRuntime,
}, files)
expectNoCallsToHelm(app)
// A regression shows up as a hang, not as an error, so run the listing in
// a goroutine and fail fast instead of waiting for the go test timeout.
type result struct {
out string
err error
}
done := make(chan result, 1)
go func() {
var listErr error
out, err := testutil.CaptureStdout(func() {
listErr = app.ListReleases(configImpl{skipCharts: true, output: "json"})
})
if err == nil {
err = listErr
}
done <- result{out: out, err: err}
}()
var res result
select {
case res = <-done:
case <-time.After(60 * time.Second):
t.Fatalf("ListReleases did not return within 60s for %d states: deadlock while collecting releases", stateCount)
}
assert.NoError(t, res.err)
var releases []HelmRelease
if err := json.Unmarshal([]byte(res.out), &releases); err != nil {
t.Fatalf("failed to parse JSON output: %v", err)
}
assert.Len(t, releases, stateCount, "expected one release per state")
}
func TestListWithLockFileVersion(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.yaml": `
+190
View File
@@ -0,0 +1,190 @@
package app
import (
goContext "context"
"os"
"path/filepath"
"testing"
"github.com/helmfile/vals"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v1 "go.opentelemetry.io/proto/otlp/trace/v1"
"github.com/helmfile/helmfile/pkg/exectest"
ffs "github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/telemetry"
"github.com/helmfile/helmfile/pkg/telemetry/otlptest"
)
// TestLoadSpanHierarchy drives a template run with telemetry enabled and the
// exectest fake helm, then asserts the state-loading span tree:
// root → discover_states → load → { render, parse }. This is the golden test
// for the app-layer context plumbing (docs/proposals/otel-tracing.md §10.3).
func TestLoadSpanHierarchy(t *testing.T) {
rec := otlptest.NewRecorder(t)
otlptest.SetupTelemetry(t, rec, "helmfile template")
files := map[string]string{
"/path/to/helmfile.yaml.gotmpl": `
releases:
- name: demo
chart: incubator/raw
`,
}
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
helm := &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: helmexec.NewLogger(os.Stderr, "warn"),
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
ctx: telemetry.CommandContext(),
}, files)
err = app.Template(applyConfig{
concurrency: 1,
includeTransitiveNeeds: true,
logger: app.Logger,
})
require.NoError(t, err)
otlptest.ShutdownTelemetry(t)
spans := rec.Spans(t)
find := func(name string) *v1.Span {
return otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == name }, name)
}
root := find("helmfile template")
discover := find("helmfile.discover_states")
load := find("helmfile.load")
render := find("helmfile.render")
parse := find("helmfile.parse")
// Every span joins the command trace started by SetupTelemetry.
for _, s := range []*v1.Span{discover, load, render, parse} {
assert.Equal(t, root.TraceId, s.TraceId, "%s must join the command trace", s.Name)
}
assert.Equal(t, root.SpanId, discover.ParentSpanId, "discover nests under root")
assert.Equal(t, root.SpanId, load.ParentSpanId, "load nests under root")
assert.Equal(t, load.SpanId, render.ParentSpanId, "render nests under load")
assert.Equal(t, load.SpanId, parse.ParentSpanId, "parse nests under load")
file, ok := otlptest.AttrString(load, "helmfile.state_file")
require.True(t, ok)
assert.Contains(t, file, "helmfile.yaml.gotmpl")
}
// TestLoadSpansAbsentWhenTelemetryDisabled pins that with telemetry disabled
// (the default) a template run behaves exactly as before and exports nothing.
func TestLoadSpansAbsentWhenTelemetryDisabled(t *testing.T) {
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: demo
chart: incubator/raw
`,
}
helm := &exectest.Helm{}
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: helmexec.NewLogger(os.Stderr, "warn"),
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
ctx: goContext.Background(),
}, files)
tmplErr := app.Template(applyConfig{
concurrency: 1,
logger: app.Logger,
})
require.NoError(t, tmplErr)
}
// TestReleaseSpanHierarchy runs a sync with the exectest fake helm and
// asserts per-release spans: present, parented under the load span, and
// carrying the release identity attributes.
func TestReleaseSpanHierarchy(t *testing.T) {
rec := otlptest.NewRecorder(t)
otlptest.SetupTelemetry(t, rec, "helmfile sync")
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: demo
chart: incubator/raw
namespace: apps
`,
}
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
helm := &exectest.Helm{}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: helmexec.NewLogger(os.Stderr, "warn"),
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
ctx: telemetry.CommandContext(),
}, files)
syncErr := app.Sync(applyConfig{
concurrency: 1,
logger: app.Logger,
})
require.NoError(t, syncErr)
otlptest.ShutdownTelemetry(t)
spans := rec.Spans(t)
release := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "helmfile.release.sync" }, "release sync span")
load := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "helmfile.load" }, "load span")
assert.Equal(t, load.TraceId, release.TraceId, "release span must join the load span's trace")
assert.Equal(t, load.SpanId, release.ParentSpanId, "release span must nest under the load span")
name, ok := otlptest.AttrString(release, "helmfile.release")
require.True(t, ok)
assert.Equal(t, "demo", name)
ns, ok := otlptest.AttrString(release, "helmfile.namespace")
require.True(t, ok)
assert.Equal(t, "apps", ns)
chart, ok := otlptest.AttrString(release, "helmfile.chart")
require.True(t, ok)
assert.Equal(t, "incubator/raw", chart)
}
+67
View File
@@ -0,0 +1,67 @@
package app
import (
gocontext "context"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"github.com/helmfile/helmfile/pkg/config"
"github.com/helmfile/helmfile/pkg/telemetry"
)
func newAppForTest() *App {
globalImpl := config.NewGlobalImpl(&config.GlobalOptions{})
printEnvImpl := config.NewPrintEnvImpl(globalImpl, &config.PrintEnvOptions{})
return New(printEnvImpl)
}
func requireCancelable(t *testing.T, a *App) {
t.Helper()
require.NotNil(t, a.ctx)
require.NotNil(t, Cancel)
Cancel()
select {
case <-a.ctx.Done():
case <-time.After(time.Second):
t.Fatal("app context was not canceled by app.Cancel()")
}
}
// TestNewPreservesContextContract pins the app.New context behavior that the
// telemetry integration relies on: with tracing disabled (the default), the
// app context is rooted at context.Background and remains cancellable exactly
// as before the telemetry.CommandContext() change.
func TestNewPreservesContextContract(t *testing.T) {
a := newAppForTest()
requireCancelable(t, a)
}
// TestNewAdoptsTelemetryCommandContext verifies that app.New derives its
// context from the telemetry command span when tracing is enabled: the app
// context carries the command span's trace, so downstream spans nest under it,
// and cancellation still works.
func TestNewAdoptsTelemetryCommandContext(t *testing.T) {
t.Setenv("OTEL_TRACES_EXPORTER", "none")
t.Cleanup(func() {
_ = telemetry.Shutdown(gocontext.Background(), nil, 0)
})
telemetry.Setup(gocontext.Background(), telemetry.Options{Enabled: true, Version: "test"})
telemetry.StartCommandSpan("helmfile test")
commandSC := trace.SpanFromContext(telemetry.CommandContext()).SpanContext()
require.True(t, commandSC.IsValid(), "command span should be recording")
a := newAppForTest()
// WithCancel preserves context values, so the command span — and its trace
// ID — must still be visible through the app context.
appSC := trace.SpanFromContext(a.ctx).SpanContext()
require.True(t, appSC.IsValid())
require.Equal(t, commandSC.TraceID(), appSC.TraceID(), "app context must derive from the command span context")
requireCancelable(t, a)
}
+1 -1
View File
@@ -38,7 +38,7 @@ releases:
// Run ListReleases multiple times to verify consistent ordering
var outputs []string
for i := 0; i < 5; i++ {
for i := range 5 {
var buffer bytes.Buffer
syncWriter := testhelper.NewSyncWriter(&buffer)
logger := helmexec.NewLogger(syncWriter, "debug")
+22 -3
View File
@@ -13,6 +13,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/helmfile/vals"
@@ -2369,6 +2370,7 @@ type configImpl struct {
skipTests bool
skipSchemaValidation bool
skipRefresh bool
allowFailedReleases bool
skipNeeds bool
includeNeeds bool
@@ -2416,6 +2418,10 @@ func (c configImpl) SkipRefresh() bool {
return c.skipRefresh
}
func (c configImpl) AllowFailedReleases() bool {
return c.allowFailedReleases
}
func (c configImpl) SkipNeeds() bool {
return c.skipNeeds
}
@@ -2508,6 +2514,7 @@ type applyConfig struct {
skipCRDs bool
skipDeps bool
skipRefresh bool
allowFailedReleases bool
skipNeeds bool
includeNeeds bool
includeTransitiveNeeds bool
@@ -2550,6 +2557,8 @@ type applyConfig struct {
trackMode string
trackTimeout int
trackLogs bool
trackLogsUntilReady bool
trackLogsInterval time.Duration
trackFailOnError bool
// template-only options
@@ -2609,6 +2618,10 @@ func (a applyConfig) SkipRefresh() bool {
return a.skipRefresh
}
func (a applyConfig) AllowFailedReleases() bool {
return a.allowFailedReleases
}
func (a applyConfig) SkipNeeds() bool {
return a.skipNeeds
}
@@ -2790,10 +2803,18 @@ func (a applyConfig) TrackLogs() bool {
return a.trackLogs
}
func (a applyConfig) TrackLogsUntilReady() bool {
return a.trackLogsUntilReady
}
func (a applyConfig) TrackFailedLogs() bool {
return false
}
func (a applyConfig) TrackLogsInterval() time.Duration {
return a.trackLogsInterval
}
func (a applyConfig) HelmStuckGrace() int {
return 0
}
@@ -5059,9 +5080,7 @@ func TestGetArgs(t *testing.T) {
for _, test := range tests {
Helmdefaults := state.HelmSpec{KubeContext: "test", Args: test.defaultArgs}
testState := &state.HelmState{
ReleaseSetSpec: state.ReleaseSetSpec{
HelmDefaults: Helmdefaults,
},
HelmDefaults: Helmdefaults,
}
receivedArgs := GetArgs(test.args, testState)
+17
View File
@@ -1,6 +1,8 @@
package app
import (
"time"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/agent/llm"
@@ -18,6 +20,7 @@ type ConfigProvider interface {
RepoRetry() int
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
SequentialHelmfiles() bool
FileOrDir() string
@@ -62,6 +65,7 @@ type ApplyConfigProvider interface {
SkipCRDs() bool
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
Wait() bool
WaitRetries() int
WaitForJobs() bool
@@ -99,7 +103,9 @@ type ApplyConfigProvider interface {
TrackMode() string
TrackTimeout() int
TrackLogs() bool
TrackLogsUntilReady() bool
TrackFailedLogs() bool
TrackLogsInterval() time.Duration
HelmStuckGrace() int
TrackFailOnError() bool
@@ -126,6 +132,7 @@ type SyncConfigProvider interface {
SkipCRDs() bool
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
Wait() bool
WaitRetries() int
WaitForJobs() bool
@@ -142,7 +149,9 @@ type SyncConfigProvider interface {
TrackMode() string
TrackTimeout() int
TrackLogs() bool
TrackLogsUntilReady() bool
TrackFailedLogs() bool
TrackLogsInterval() time.Duration
HelmStuckGrace() int
TrackFailOnError() bool
@@ -174,6 +183,7 @@ type DiffConfigProvider interface {
SkipCRDs() bool
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
IncludeTests() bool
@@ -231,6 +241,7 @@ type DestroyConfigProvider interface {
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
SkipCharts() bool
DeleteWait() bool
DeleteTimeout() int
@@ -246,6 +257,7 @@ type TestConfigProvider interface {
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
Timeout() int
Cleanup() bool
Logs() bool
@@ -260,6 +272,7 @@ type LintConfigProvider interface {
Set() []string
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
SkipCleanup() bool
DAGConfig
@@ -277,6 +290,7 @@ type UnittestConfigProvider interface {
DebugPlugin() bool
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
SkipCleanup() bool
DAGConfig
@@ -287,6 +301,7 @@ type UnittestConfigProvider interface {
type FetchConfigProvider interface {
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
OutputDir() string
OutputDirTemplate() string
WriteOutput() bool
@@ -306,6 +321,7 @@ type TemplateConfigProvider interface {
Validate() bool
SkipDeps() bool
SkipRefresh() bool
AllowFailedReleases() bool
SkipCleanup() bool
SkipTests() bool
OutputDir() string
@@ -341,6 +357,7 @@ type WriteValuesConfigProvider interface {
type StatusesConfigProvider interface {
Args() string
AllowFailedReleases() bool
concurrencyConfig
}
+4 -4
View File
@@ -19,11 +19,11 @@ func TestContextConcurrentAccess(t *testing.T) {
wg.Add(numGoroutines)
// Launch multiple goroutines that concurrently update the repos map
for i := 0; i < numGoroutines; i++ {
for i := range numGoroutines {
go func(goroutineID int) {
defer wg.Done()
for j := 0; j < numReposPerGoroutine; j++ {
for j := range numReposPerGoroutine {
repoKey := "repo-" + string(rune('0'+goroutineID)) + "-" + string(rune('0'+j))
ctx.mu.Lock()
@@ -129,10 +129,10 @@ func TestContextConcurrentReadWrite(t *testing.T) {
var wg sync.WaitGroup
// Launch multiple goroutines for each repo
for i := 0; i < numRepos; i++ {
for i := range numRepos {
repoKey := "repo-" + string(rune('0'+i)) + "=https://example.com"
for j := 0; j < numGoroutinesPerRepo; j++ {
for range numGoroutinesPerRepo {
wg.Add(1)
go func(key string) {
defer wg.Done()
+289
View File
@@ -0,0 +1,289 @@
package app
import (
"fmt"
"sync"
"testing"
"github.com/helmfile/vals"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/exectest"
ffs "github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/state"
)
func fakeConverge(failures map[string]bool, executed *[]string) func(*state.HelmState, helmexec.Interface) (bool, []error) {
return func(st *state.HelmState, _ helmexec.Interface) (bool, []error) {
var errs []error
for i := range st.Releases {
release := &st.Releases[i]
*executed = append(*executed, release.Name)
if failures[release.Name] {
err := fmt.Errorf("failed processing release %s: boom", release.Name)
errs = append(errs, state.NewReleaseError(release, err, state.ReleaseErrorCodeFailure))
}
}
return len(st.Releases) > 0, errs
}
}
func TestWithBatches_FailFastByDefault(t *testing.T) {
templated := &state.HelmState{}
var executed []string
batches := [][]state.Release{
{
state.Release{Name: "database", Needs: nil, ContinueOnError: nil},
state.Release{Name: "cache", Needs: nil, ContinueOnError: nil},
},
{
state.Release{Name: "worker", Needs: nil, ContinueOnError: nil},
},
}
any, errs := withBatches("processing", templated, batches, &exectest.Helm{}, zap.NewNop().Sugar(), fakeConverge(map[string]bool{"database": true}, &executed))
require.False(t, any)
require.Len(t, errs, 1)
require.Equal(t, []string{"database", "cache"}, executed)
require.Equal(t, "failed processing release database: boom", errs[0].Error())
}
func TestWithBatches_ContinueOnErrorKeepsIndependentBranches(t *testing.T) {
templated := &state.HelmState{}
var executed []string
batches := [][]state.Release{
{
state.Release{Name: "database", Needs: nil, ContinueOnError: new(true)},
state.Release{Name: "cache", Needs: nil, ContinueOnError: nil},
},
{
state.Release{Name: "worker", Needs: nil, ContinueOnError: nil},
},
}
any, errs := withBatches("processing", templated, batches, &exectest.Helm{}, zap.NewNop().Sugar(), fakeConverge(map[string]bool{"database": true}, &executed))
require.True(t, any)
require.Len(t, errs, 1)
require.Equal(t, []string{"database", "cache", "worker"}, executed)
require.Equal(t, "failed processing release database: boom", errs[0].Error())
}
func TestWithBatches_BlocksDependentReleasesAndAggregatesErrors(t *testing.T) {
templated := &state.HelmState{}
var executed []string
batches := [][]state.Release{
{
state.Release{Name: "database", Needs: nil, ContinueOnError: new(true)},
},
{
state.Release{Name: "backend", Needs: []string{"database"}, ContinueOnError: new(true)},
state.Release{Name: "worker", Needs: nil, ContinueOnError: nil},
},
{
state.Release{Name: "frontend", Needs: []string{"backend"}, ContinueOnError: new(true)},
},
}
any, errs := withBatches("processing", templated, batches, &exectest.Helm{}, zap.NewNop().Sugar(), fakeConverge(map[string]bool{"database": true}, &executed))
require.True(t, any)
require.Len(t, errs, 3)
require.Equal(t, []string{"database", "worker"}, executed)
require.Equal(t, "failed processing release database: boom", errs[0].Error())
require.Contains(t, errs[1].Error(), "release \"backend\" was skipped because dependency \"database\" failed")
require.Contains(t, errs[2].Error(), "release \"frontend\" was skipped because dependency \"backend\" failed")
}
func TestWithBatches_AggregatesMultipleReleaseErrors(t *testing.T) {
templated := &state.HelmState{}
var executed []string
batches := [][]state.Release{
{
state.Release{Name: "app-a", Needs: nil, ContinueOnError: new(true)},
state.Release{Name: "app-b", Needs: nil, ContinueOnError: new(true)},
},
{
state.Release{Name: "worker", Needs: nil, ContinueOnError: nil},
},
}
any, errs := withBatches("processing", templated, batches, &exectest.Helm{}, zap.NewNop().Sugar(), fakeConverge(map[string]bool{"app-a": true, "app-b": true}, &executed))
require.True(t, any)
require.Len(t, errs, 2)
require.Equal(t, []string{"app-a", "app-b", "worker"}, executed)
for _, releaseName := range []string{"app-a", "app-b"} {
require.Contains(t, fmt.Sprint(errs), releaseName)
}
}
func TestWithBatches_TreatsNonFailureReleaseErrorCodeAsFatal(t *testing.T) {
templated := &state.HelmState{}
var executed []string
// e.g. the helm-diff "changes detected" exit code 2, which is not a
// release failure and must never enable continuation.
converge := func(st *state.HelmState, _ helmexec.Interface) (bool, []error) {
var errs []error
for i := range st.Releases {
release := &st.Releases[i]
executed = append(executed, release.Name)
errs = append(errs, state.NewReleaseError(release, fmt.Errorf("release %s: changed", release.Name), 2))
}
return false, errs
}
batches := [][]state.Release{
{
state.Release{Name: "database", Needs: nil, ContinueOnError: new(true)},
},
{
state.Release{Name: "worker", Needs: nil, ContinueOnError: nil},
},
}
any, errs := withBatches("processing", templated, batches, &exectest.Helm{}, zap.NewNop().Sugar(), converge)
require.False(t, any)
require.Len(t, errs, 1)
require.Equal(t, []string{"database"}, executed)
}
// TestSync_ContinueOnError exercises the whole sync pipeline with the exectest
// fake helm: the release named "error-*" fails to upgrade, and continueOnError
// on that release must (1) let the independent release still be deployed,
// (2) skip the dependent release with an explicit error, and (3) keep the
// overall command failing with a non-zero exit code.
func TestSync_ContinueOnError(t *testing.T) {
newApp := func(t *testing.T, helm *exectest.Helm, files map[string]string, logger *zap.SugaredLogger) *App {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
return appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: ffs.DefaultFileSystem(),
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
}
t.Run("independent release continues and dependent release is skipped", func(t *testing.T) {
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: error-database
chart: incubator/raw
namespace: default
continueOnError: true
- name: independent-app
chart: incubator/raw
namespace: default
- name: dependent-backend
chart: incubator/raw
namespace: default
needs:
- default/error-database
`,
}
logger := zap.NewNop().Sugar()
app := newApp(t, helm, files, logger)
syncErr := app.Sync(applyConfig{
concurrency: 1,
logger: logger,
})
require.Error(t, syncErr, "sync must exit non-zero even when errors are tolerated")
appErr, ok := syncErr.(*Error)
require.True(t, ok, "expected *app.Error, got %T", syncErr)
assert.Equal(t, 1, appErr.Code())
// independent-app must have been deployed despite error-database failing
synced := make([]string, 0, len(helm.Releases))
for _, r := range helm.Releases {
synced = append(synced, r.Name)
}
assert.Contains(t, synced, "independent-app")
assert.NotContains(t, synced, "dependent-backend", "release depending on a failed release must be skipped")
assert.NotContains(t, synced, "error-database")
assert.Contains(t, syncErr.Error(), `release "dependent-backend" was skipped because dependency "error-database" failed`)
})
t.Run("fail-fast remains the default", func(t *testing.T) {
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
files := map[string]string{
"/path/to/helmfile.yaml": `
releases:
- name: error-database
chart: incubator/raw
namespace: default
- name: base
chart: incubator/raw
namespace: default
- name: later-app
chart: incubator/raw
namespace: default
needs:
- default/base
`,
}
logger := zap.NewNop().Sugar()
app := newApp(t, helm, files, logger)
syncErr := app.Sync(applyConfig{
concurrency: 1,
logger: logger,
})
require.Error(t, syncErr)
synced := make([]string, 0, len(helm.Releases))
for _, r := range helm.Releases {
synced = append(synced, r.Name)
}
// base shares the first batch with error-database and is therefore
// still attempted, but the second batch must never run once the
// failure is reported.
assert.Contains(t, synced, "base")
assert.NotContains(t, synced, "later-app", "without continueOnError the default fail-fast behavior must stop the run")
})
}
+28 -8
View File
@@ -2,6 +2,7 @@ package app
import (
"bytes"
goContext "context"
"errors"
"fmt"
"os"
@@ -10,6 +11,8 @@ import (
"dario.cat/mergo"
"github.com/helmfile/vals"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/environment"
@@ -19,6 +22,7 @@ import (
"github.com/helmfile/helmfile/pkg/policy"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/state"
"github.com/helmfile/helmfile/pkg/telemetry"
)
const (
@@ -32,6 +36,11 @@ type desiredStateLoader struct {
overrideKustomizeBinary string
enableLiveOutput bool
// traceCtx carries the helmfile.load span context; render/parse spans
// attach to it. nil falls back to context.Background, which with tracing
// disabled makes span starts no-ops.
traceCtx goContext.Context
env string
namespace string
chart string
@@ -47,6 +56,14 @@ type desiredStateLoader struct {
lockFilePath string
}
// spanCtx returns the loader's span parent context.
func (ld *desiredStateLoader) spanCtx() goContext.Context {
if ld.traceCtx != nil {
return ld.traceCtx
}
return goContext.Background()
}
func (ld *desiredStateLoader) Load(f string, opts LoadOpts) (*state.HelmState, error) {
var overrodeEnv *environment.Environment
@@ -238,6 +255,16 @@ func (a *desiredStateLoader) rawLoad(yaml []byte, baseDir, file string, evaluate
return st, nil
}
// parsePart wraps rawLoad for one document part in a helmfile.parse span.
func (ld *desiredStateLoader) parsePart(rawContent []byte, baseDir, filename string, evaluateBases bool, env, overrodeEnv *environment.Environment) (*state.HelmState, error) {
_, span := telemetry.Tracer(telemetry.ScopeHelmfile).Start(ld.spanCtx(), "helmfile.parse",
trace.WithAttributes(attribute.String("helmfile.state_file", filename)),
)
defer span.End()
return ld.rawLoad(rawContent, baseDir, filename, evaluateBases, env, overrodeEnv)
}
func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, baseDir, filename string, content []byte, evaluateBases bool) (*state.HelmState, error) {
// Allows part-splitting to work with CLRF-ed content
normalizedContent := bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
@@ -280,14 +307,7 @@ func (ld *desiredStateLoader) load(env, overrodeEnv *environment.Environment, ba
rawContent = part
}
currentState, err := ld.rawLoad(
rawContent,
baseDir,
filename,
evaluateBases,
env,
overrodeEnv,
)
currentState, err := ld.parsePart(rawContent, baseDir, filename, evaluateBases, env, overrodeEnv)
if err != nil {
return nil, err
}
+5
View File
@@ -39,6 +39,7 @@ type destroyConfig struct {
interactive bool
skipDeps bool
skipRefresh bool
allowFailedReleases bool
logger *zap.SugaredLogger
includeTransitiveNeeds bool
skipCharts bool
@@ -78,6 +79,10 @@ func (d destroyConfig) SkipRefresh() bool {
return d.skipRefresh
}
func (d destroyConfig) AllowFailedReleases() bool {
return d.allowFailedReleases
}
func (d destroyConfig) IncludeTransitiveNeeds() bool {
return d.includeTransitiveNeeds
}
+5
View File
@@ -25,6 +25,7 @@ type diffConfig struct {
skipCRDs bool
skipDeps bool
skipRefresh bool
allowFailedReleases bool
includeTests bool
skipNeeds bool
includeNeeds bool
@@ -87,6 +88,10 @@ func (a diffConfig) SkipRefresh() bool {
return a.skipRefresh
}
func (a diffConfig) AllowFailedReleases() bool {
return a.allowFailedReleases
}
func (a diffConfig) IncludeTests() bool {
return a.includeTests
}
+152
View File
@@ -0,0 +1,152 @@
package app
import (
"path/filepath"
"slices"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/remote"
"github.com/helmfile/helmfile/pkg/state"
)
// RootHelmfileDir returns the absolute directory of the top-level helmfile,
// or "" when the root is a remote URL or otherwise unresolvable. Callers
// treat "" as "dir-based filtering not available". The value is cached by
// resolveRootHelmfileDir; it must be invoked before any goroutine fan-out
// or working-directory change so that the CWD-sensitive resolution lands
// against the process CWD at command start, not the (possibly chdir'd)
// directory of a leaf helmfile being processed.
func (a *App) RootHelmfileDir() string {
return a.rootHelmfileDir
}
// resolveRootHelmfileDir computes and caches the root directory anchor for
// dir-based filtering. Idempotent; safe to call multiple times. Must be
// called from the top-level entry of each command, before within() or any
// goroutine spawn.
func (a *App) resolveRootHelmfileDir() {
a.rootHelmfileDirOnce.Do(func() {
a.rootHelmfileDir = a.computeRootHelmfileDir()
})
}
func (a *App) computeRootHelmfileDir() string {
dir, err := DirSelectorRoot(a.fs, a.FileOrDir)
if err != nil {
a.Logger.Debugf("dir selector unavailable: cannot resolve root helmfile directory: %v", err)
}
return dir
}
// DirSelectorRoot returns the absolute directory that dir= selector values
// are relative to for the given -f value: the working directory when it is
// empty, the directory itself when it points to one, the file's directory
// otherwise. Returns "" for a remote helmfile.
func DirSelectorRoot(fs *filesystem.FileSystem, fileOrDir string) (string, error) {
if fileOrDir == "" {
return fs.Getwd()
}
if remote.IsRemote(fileOrDir) {
return "", nil
}
absPath, err := fs.Abs(fileOrDir)
if err != nil {
return "", err
}
if fs.DirectoryExistsAt(absPath) {
return absPath, nil
}
return filepath.Dir(absPath), nil
}
// dirSelectorGroup holds the positive dir= target values inside one -l
// argument. An empty targets slice means the group has no dir= constraint
// and is therefore path-permissive during traversal-skip.
type dirSelectorGroup struct {
targets []string
}
// extractDirSelectorTargets returns one dirSelectorGroup per -l argument
// with that group's positive dir= values. Negative dir!= is intentionally
// dropped: skipping a branch on a negative constraint would require proving
// it contains only excluded releases, which generally requires loading it.
// Malformed groups are treated as path-permissive so traversal does not
// short-circuit on selector strings the filter will later report on.
// Returns nil when no positive dir= appears, so callers can skip the check.
func extractDirSelectorTargets(selectors []string) []dirSelectorGroup {
if len(selectors) == 0 {
return nil
}
groups := make([]dirSelectorGroup, 0, len(selectors))
anyDir := false
for _, s := range selectors {
targets, err := state.PositiveDirTargets(s)
if err != nil {
groups = append(groups, dirSelectorGroup{})
continue
}
groups = append(groups, dirSelectorGroup{targets: targets})
if len(targets) > 0 {
anyDir = true
}
}
if !anyDir {
return nil
}
return groups
}
// skipForDirFilter reports whether a `helmfiles:` entry can be skipped for
// the given selectors. Remote entries and unresolvable roots always descend.
func (a *App) skipForDirFilter(stateDir, entryPath string, selectors []string) bool {
rootDir := a.RootHelmfileDir()
if rootDir == "" || remote.IsRemote(entryPath) {
return false
}
groups := extractDirSelectorTargets(selectors)
if len(groups) == 0 {
return false
}
entryDir, ok := a.entryDirRelativeToRoot(rootDir, stateDir, entryPath)
if !ok {
return false
}
return !shouldDescendForDirFilter(entryDir, groups)
}
// entryDirRelativeToRoot returns the directory holding the helmfile(s) of a
// `helmfiles:` entry, relative to rootDir: the entry itself when it is a
// directory, its parent otherwise. Returns false when the entry lies outside
// rootDir, signaling the caller to descend conservatively.
func (a *App) entryDirRelativeToRoot(rootDir, stateDir, entryPath string) (string, bool) {
abs := entryPath
if !filepath.IsAbs(abs) {
abs = filepath.Join(stateDir, entryPath)
}
if !a.fs.DirectoryExistsAt(abs) {
abs = filepath.Dir(abs)
}
return state.DirRelativeToRoot(rootDir, abs)
}
// shouldDescendForDirFilter returns true when a sub-helmfile located in
// entryDir (relative to the root) could contain a release matching at least
// one of the dir-selector groups. A branch is relevant for a dir=target when
// it lives at or below target, or when target lives below it (e.g. an
// aggregator at apps/helmfile.yaml for target apps/x). The decision only
// looks at the entry's own location, so it assumes nested entries stay inside
// their parent's directory.
func shouldDescendForDirFilter(entryDir string, groups []dirSelectorGroup) bool {
if len(groups) == 0 || entryDir == "." {
return true
}
for _, g := range groups {
conflicts := slices.ContainsFunc(g.targets, func(target string) bool {
return !state.DirsCompatible(entryDir, target)
})
if !conflicts {
return true
}
}
return false
}
+360
View File
@@ -0,0 +1,360 @@
package app
import (
"strings"
"testing"
"github.com/helmfile/vals"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/helmfile/helmfile/pkg/testhelper"
)
func TestExtractDirSelectorTargets(t *testing.T) {
tests := []struct {
name string
selectors []string
wantNil bool
wantPer []int
}{
{
name: "nil selectors",
selectors: nil,
wantNil: true,
},
{
name: "no dir selectors",
selectors: []string{"name=foo", "namespace=bar"},
wantNil: true,
},
{
name: "single dir selector",
selectors: []string{"dir=apps/x"},
wantPer: []int{1},
},
{
name: "dir with companion in same group",
selectors: []string{"dir=apps/x,name=foo"},
wantPer: []int{1},
},
{
name: "dir spread across groups",
selectors: []string{"dir=apps/x", "dir=apps/y"},
wantPer: []int{1, 1},
},
{
name: "negative dir is ignored",
selectors: []string{"dir!=apps/x"},
wantNil: true,
},
{
name: "mixed group with no dir and group with dir",
selectors: []string{"name=foo", "dir=apps/x"},
wantPer: []int{0, 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
groups := extractDirSelectorTargets(tt.selectors)
if tt.wantNil {
assert.Nil(t, groups)
return
}
assert.Len(t, groups, len(tt.wantPer))
for i, want := range tt.wantPer {
assert.Len(t, groups[i].targets, want)
}
})
}
}
func TestShouldDescendForDirFilter(t *testing.T) {
const target = "opendesk/helmfile/apps/openproject"
tests := []struct {
name string
entryDir string
groups []dirSelectorGroup
wantSkip bool
}{
{
name: "no groups means caller skipped check; treat as descend",
entryDir: "opendesk/helmfile/apps/openproject",
groups: nil,
},
{
name: "entry in target dir → descend",
entryDir: "opendesk/helmfile/apps/openproject",
groups: []dirSelectorGroup{{targets: []string{target}}},
},
{
name: "entry below target → descend",
entryDir: "opendesk/helmfile/apps/openproject/sub",
groups: []dirSelectorGroup{{targets: []string{target}}},
},
{
name: "sibling service → skip",
entryDir: "opendesk/helmfile/apps/xwiki",
groups: []dirSelectorGroup{{targets: []string{target}}},
wantSkip: true,
},
{
name: "name-collision is not a match → skip",
entryDir: "opendesk/helmfile/apps/openproject-extras",
groups: []dirSelectorGroup{{targets: []string{target}}},
wantSkip: true,
},
{
name: "entry is ancestor of target → descend",
entryDir: "opendesk",
groups: []dirSelectorGroup{{targets: []string{target}}},
},
{
name: "entry in root dir → descend",
entryDir: ".",
groups: []dirSelectorGroup{{targets: []string{target}}},
},
{
name: "group with no targets is permissive",
entryDir: "opendesk/helmfile/apps/xwiki",
groups: []dirSelectorGroup{{targets: nil}, {targets: []string{target}}},
},
{
name: "multiple targets in one group: all must allow",
entryDir: "opendesk/helmfile/apps/openproject",
groups: []dirSelectorGroup{{targets: []string{target, "opendesk/helmfile/apps/xwiki"}}},
wantSkip: true,
},
{
name: "OR across groups: either matches descends",
entryDir: "opendesk/helmfile/apps/openproject",
groups: []dirSelectorGroup{{targets: []string{"opendesk/helmfile/apps/xwiki"}}, {targets: []string{target}}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, !tt.wantSkip, shouldDescendForDirFilter(tt.entryDir, tt.groups))
})
}
}
func newDirFilterTestApp(t *testing.T, files map[string]string, fileOrDir string, selectors []string) (*App, *testhelper.TestFs) {
t.Helper()
testFs := testhelper.NewTestFs(files)
testFs.Cwd = "/workspace"
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
app := &App{
OverrideHelmBinary: DefaultHelmBinary,
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: newAppTestLogger(),
valsRuntime: valsRuntime,
FileOrDir: fileOrDir,
Selectors: selectors,
}
app = injectFs(app, testFs)
expectNoCallsToHelm(app)
return app, testFs
}
// TestProcessNestedHelmfiles_DirFilterSkipsSiblings drives the full visit
// chain against an opendesk-shaped fixture: a root helmfile that includes an
// aggregator, which in turn lists several sibling service helmfiles. With
// `-l dir=…/openproject` only the matching service helmfile should be opened,
// asserted by counting how often a helmfile-child.yaml file is read. The
// release names also let us confirm the post-load filter sees only the
// expected entry.
func TestProcessNestedHelmfiles_DirFilterSkipsSiblings(t *testing.T) {
files := map[string]string{
"/workspace/helmfile.yaml": `
helmfiles:
- opendesk/helmfile_generic.yaml
`,
"/workspace/opendesk/helmfile_generic.yaml": `
helmfiles:
- apps/openproject/helmfile-child.yaml
- apps/xwiki/helmfile-child.yaml
- apps/jitsi/helmfile-child.yaml
`,
"/workspace/opendesk/apps/openproject/helmfile-child.yaml": `
releases:
- name: openproject-web
chart: stable/openproject
`,
"/workspace/opendesk/apps/xwiki/helmfile-child.yaml": `
releases:
- name: xwiki
chart: stable/xwiki
`,
"/workspace/opendesk/apps/jitsi/helmfile-child.yaml": `
releases:
- name: jitsi
chart: stable/jitsi
`,
}
app, testFs := newDirFilterTestApp(t, files, "/workspace/helmfile.yaml", []string{"dir=opendesk/apps/openproject"})
err := app.ForEachState(Noop, false, SetFilter(true))
assert.NoError(t, err)
childReads := 0
for _, r := range testFs.SuccessfulReads() {
if strings.HasSuffix(r, "helmfile-child.yaml") {
childReads++
}
}
assert.Equal(t, 1, childReads, "exactly one helmfile-child.yaml should be read (the matching one), got reads=%v", testFs.SuccessfulReads())
}
// TestProcessNestedHelmfiles_DirFilterExcludesTopLevelReleases verifies that a
// dir filter targeting a nested subtree excludes releases declared directly
// in the root helmfile (whose dirLabel is ".") while still descending into the
// matching sub-helmfile. The root file itself must still be read because its
// `helmfiles:` directive is what points to the nested branch. Uses distinct
// filenames per level so the read log is unambiguous.
func TestProcessNestedHelmfiles_DirFilterExcludesTopLevelReleases(t *testing.T) {
files := map[string]string{
"/workspace/root.yaml": `
releases:
- name: root-release
chart: stable/root
helmfiles:
- apps/openproject/leaf.yaml
`,
"/workspace/apps/openproject/leaf.yaml": `
releases:
- name: openproject-web
chart: stable/openproject
`,
}
app, testFs := newDirFilterTestApp(t, files, "/workspace/root.yaml", []string{"dir=apps/openproject"})
err := app.ForEachState(Noop, false, SetFilter(true))
assert.NoError(t, err)
reads := testFs.SuccessfulReads()
readSet := map[string]bool{}
for _, r := range reads {
readSet[r] = true
}
assert.True(t, readSet["root.yaml"], "root helmfile must be parsed to learn its helmfiles: list, reads=%v", reads)
assert.True(t, readSet["leaf.yaml"], "matching nested helmfile must be descended into, reads=%v", reads)
}
// TestProcessNestedHelmfiles_DirDotIsRejected pins down the current behavior
// that `dir=.` and equivalent forms (`dir=./`, `dir=apps/..`) are rejected at
// selector parse time. Semantics for `.` were under discussion: matching
// everything (no-op), matching only root-level releases (useful but uneven),
// or rejecting outright. Outright rejection is the current decision; this
// test makes any change to that policy a deliberate code change.
func TestProcessNestedHelmfiles_DirDotIsRejected(t *testing.T) {
files := map[string]string{
"/workspace/root.yaml": `
releases:
- name: root-release
chart: stable/root
`,
}
for _, selector := range []string{"dir=.", "dir=./", "dir=apps/.."} {
t.Run(selector, func(t *testing.T) {
app, _ := newDirFilterTestApp(t, files, "/workspace/root.yaml", []string{selector})
err := app.ForEachState(Noop, false, SetFilter(true))
assert.Error(t, err, "dir=. and equivalents should be rejected at parse time")
})
}
}
func TestSkipForDirFilter(t *testing.T) {
const rootDir = "/workspace"
const stateDir = "/workspace/opendesk"
selectors := []string{"dir=opendesk/apps/openproject"}
testFs := testhelper.NewTestFs(map[string]string{
"/workspace/opendesk/apps/openproject/helmfile.yaml": "",
"/workspace/opendesk/apps/xwiki/helmfile.yaml": "",
})
tests := []struct {
name string
entryPath string
selectors []string
rootDir string
skip bool
}{
{name: "matching entry descends", entryPath: "apps/openproject/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: false},
{name: "sibling entry is skipped", entryPath: "apps/xwiki/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: true},
{name: "remote entry is never short-circuited", entryPath: "git::https://github.com/example/xwiki.git@helmfile.yaml?ref=main", selectors: selectors, rootDir: rootDir, skip: false},
{name: "matching directory entry descends", entryPath: "apps/openproject", selectors: selectors, rootDir: rootDir, skip: false},
{name: "sibling directory entry is skipped", entryPath: "apps/xwiki", selectors: selectors, rootDir: rootDir, skip: true},
{name: "ancestor directory entry descends", entryPath: "apps", selectors: selectors, rootDir: rootDir, skip: false},
{name: "absolute sibling entry is skipped", entryPath: "/workspace/opendesk/apps/xwiki/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: true},
{name: "entry outside root descends", entryPath: "../../shared/helmfile.yaml", selectors: selectors, rootDir: rootDir, skip: false},
{name: "no dir selector descends", entryPath: "apps/xwiki/helmfile.yaml", selectors: []string{"tier=frontend"}, rootDir: rootDir, skip: false},
{name: "negative dir selector does not prune", entryPath: "apps/xwiki/helmfile.yaml", selectors: []string{"dir!=opendesk/apps/xwiki"}, rootDir: rootDir, skip: false},
{name: "unresolvable root descends", entryPath: "apps/xwiki/helmfile.yaml", selectors: selectors, rootDir: "", skip: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
app := injectFs(&App{rootHelmfileDir: tc.rootDir}, testFs)
assert.Equal(t, tc.skip, app.skipForDirFilter(stateDir, tc.entryPath, tc.selectors))
})
}
}
func TestProcessNestedHelmfiles_DirFilterSkipsSiblingDirectoryEntries(t *testing.T) {
files := map[string]string{
"/workspace/helmfile.yaml": `
helmfiles:
- apps/openproject
- apps/xwiki
`,
"/workspace/apps/openproject/leaf.yaml": `
releases:
- name: openproject-web
chart: stable/openproject
`,
"/workspace/apps/xwiki/leaf.yaml": `
releases:
- name: xwiki
chart: stable/xwiki
`,
}
app, testFs := newDirFilterTestApp(t, files, "/workspace/helmfile.yaml", []string{"dir=apps/openproject"})
for _, dir := range []string{"/workspace/apps/openproject", "/workspace/apps/xwiki"} {
testFs.GlobFixtures[dir] = []string{dir}
}
var selected []string
err := app.ForEachState(func(run *Run) (bool, []error) {
releases, err := run.state.GetSelectedReleases(false)
if err != nil {
return false, []error{err}
}
for _, r := range releases {
selected = append(selected, r.Name)
}
return len(releases) > 0, nil
}, false, SetFilter(true))
require.NoError(t, err)
leafReads := 0
for _, r := range testFs.SuccessfulReads() {
if strings.HasSuffix(r, "leaf.yaml") {
leafReads++
}
}
assert.Equal(t, 1, leafReads, "only the matching directory entry should be read, got reads=%v", testFs.SuccessfulReads())
assert.Equal(t, []string{"openproject-web"}, selected)
}
+1 -2
View File
@@ -257,8 +257,7 @@ func isDetectedChanges(err error) bool {
if err == nil {
return false
}
var appErr *Error
if errors.As(err, &appErr) {
if appErr, ok := errors.AsType[*Error](err); ok {
if appErr.code != nil && *appErr.code == 2 {
return true
}
+2 -2
View File
@@ -19,8 +19,8 @@ import (
const (
HelmRequiredVersion = "v3.18.6" // Minimum required version (supports Helm 3.x and 4.x)
HelmDiffRecommendedVersion = "v3.15.12"
HelmRecommendedVersion = "v4.2.4" // Recommended Helm 4 version
HelmDiffRecommendedVersion = "v3.15.13"
HelmRecommendedVersion = "v4.3.1" // Recommended Helm 4 version
HelmSecretsRecommendedVersion = "v4.7.4" // v4.7.0+ works with both Helm 3 (single plugin) and Helm 4 (split plugin architecture)
HelmGitRecommendedVersion = "v1.3.0"
HelmS3RecommendedVersion = "v0.16.3"
+9 -16
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
@@ -143,10 +144,8 @@ func createPluginYAML(t *testing.T, pluginsDir, pluginDirName, name, version str
func newHelmPluginMockRunner(pluginErr error) *initMockRunner {
return &initMockRunner{
executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
for _, a := range args {
if a == "--short" {
return []byte("v3.18.6"), nil
}
if slices.Contains(args, "--short") {
return []byte("v3.18.6"), nil
}
// Fail any "helm plugin ..." subcommand (install, update, etc.)
if len(args) > 0 && args[0] == "plugin" {
@@ -168,10 +167,8 @@ func TestCheckHelmPlugins_InstallErrorButPluginPresent(t *testing.T) {
// plugin.yaml on disk and then returns an error.
runner := &initMockRunner{
executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
for _, a := range args {
if a == "--short" {
return []byte("v3.18.6"), nil
}
if slices.Contains(args, "--short") {
return []byte("v3.18.6"), nil
}
if len(args) > 0 && args[0] == "plugin" && len(args) >= 3 && args[1] == "install" {
// Find which plugin is being installed by matching the repo URL.
@@ -228,10 +225,8 @@ func TestCheckHelmPlugins_UpdateUsesUninstallReinstall(t *testing.T) {
// that writes the required version to disk.
runner := &initMockRunner{
executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
for _, a := range args {
if a == "--short" {
return []byte("v3.18.6"), nil
}
if slices.Contains(args, "--short") {
return []byte("v3.18.6"), nil
}
if len(args) >= 2 && args[0] == "plugin" {
switch args[1] {
@@ -309,10 +304,8 @@ func TestCheckHelmPlugins_UpdateErrorButPluginAtRequiredVersion(t *testing.T) {
// verifies the version and warns instead of returning an error.
runner := &initMockRunner{
executeFunc: func(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
for _, a := range args {
if a == "--short" {
return []byte("v3.18.6"), nil
}
if slices.Contains(args, "--short") {
return []byte("v3.18.6"), nil
}
if len(args) >= 2 && args[0] == "plugin" {
switch args[1] {
+109
View File
@@ -0,0 +1,109 @@
package app
import (
"path/filepath"
"sync"
"testing"
"github.com/helmfile/vals"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/exectest"
ffs "github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
)
// issue2616HelmfileContent defines two releases: one that is templated fine
// (logging) and one that always fails chart preparation (error), because OCI
// charts do not support the "latest" version tag.
const issue2616HelmfileContent = `
releases:
- name: logging
chart: incubator/raw
namespace: kube-system
- name: error
chart: oci://example.com/chart/error
namespace: failed
version: latest
`
// TestTemplateAllowFailedReleases verifies the behavior of the
// --allow-failed-releases flag end to end for `helmfile template`:
// when chart preparation fails for one release, the remaining releases are
// still templated, the failed release is skipped (i.e. never executed against
// its un-prepared chart reference) and all failures are reported at the end.
func TestTemplateAllowFailedReleases(t *testing.T) {
testcases := []struct {
name string
allowFailedRelease bool
// wantTemplated contains the releases that must have been passed to
// `helm template`; the release failing chart preparation must never
// appear here.
wantTemplated []exectest.Release
}{
{
name: "default: abort on chart preparation failure",
allowFailedRelease: false,
wantTemplated: nil,
},
{
name: "allow-failed-releases: skip failed release, template the rest",
allowFailedRelease: true,
wantTemplated: []exectest.Release{{Name: "logging", Flags: []string{"--kube-context", "default", "--namespace", "kube-system"}}},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
var helm = &exectest.Helm{
FailOnUnexpectedList: true,
FailOnUnexpectedDiff: true,
DiffMutex: &sync.Mutex{},
ChartsMutex: &sync.Mutex{},
ReleasesMutex: &sync.Mutex{},
}
_ = runWithLogCapture(t, "debug", func(t *testing.T, logger *zap.SugaredLogger) {
t.Helper()
valsRuntime, err := vals.New(vals.Options{CacheSize: 32})
require.NoError(t, err)
files := map[string]string{
"/path/to/helmfile.yaml": issue2616HelmfileContent,
}
app := appWithFs(&App{
OverrideHelmBinary: DefaultHelmBinary,
fs: &ffs.FileSystem{Glob: filepath.Glob},
OverrideKubeContext: "default",
DisableKubeVersionAutoDetection: true,
Env: "default",
Logger: logger,
helms: map[helmKey]helmexec.Interface{
createHelmKey("helm", "default"): helm,
},
valsRuntime: valsRuntime,
}, files)
tmplErr := app.Template(applyConfig{
// if we check log output, concurrency must be 1. otherwise the test becomes non-deterministic.
concurrency: 1,
logger: logger,
allowFailedReleases: tc.allowFailedRelease,
})
// The chart preparation failure must be reported in both modes.
require.Error(t, tmplErr)
assert.Contains(t, tmplErr.Error(), "the version for OCI charts should be semver compliant")
// The release that failed chart preparation must never be executed.
require.Equal(t, tc.wantTemplated, helm.Templated)
})
})
}
}
+57 -11
View File
@@ -1,6 +1,7 @@
package app
import (
"errors"
"fmt"
"os"
"slices"
@@ -48,18 +49,23 @@ func (r *Run) askForConfirmation(msg string) bool {
// When skipRepos is false, SyncReposOnce still runs normally for all repos.
var commandsSkipChartPrep = []string{"write-values", "list"}
func (r *Run) prepareChartsIfNeeded(helmfileCommand string, dir string, concurrency int, opts state.ChartPrepareOptions) (map[state.PrepareChartKey]string, error) {
func (r *Run) prepareChartsIfNeeded(helmfileCommand string, dir string, concurrency int, opts state.ChartPrepareOptions) (map[state.PrepareChartKey]string, map[state.PrepareChartKey]error, error) {
// Skip chart preparation for commands that don't need chart pulls
if slices.Contains(commandsSkipChartPrep, strings.ToLower(helmfileCommand)) {
return nil, nil
return nil, nil, nil
}
releaseToChart, errs := r.state.PrepareCharts(r.helm, dir, concurrency, helmfileCommand, opts)
releaseToChart, failedReleases, errs := r.state.PrepareCharts(r.helm, dir, concurrency, helmfileCommand, opts)
if len(errs) > 0 {
return nil, fmt.Errorf("%v", errs)
if !opts.AllowFailedReleases {
// abort on first error
return nil, nil, fmt.Errorf("%v", errs)
}
// return partial results, along with the per-release errors for the failed ones
return releaseToChart, failedReleases, &MultiError{Errors: errs}
}
return releaseToChart, nil
return releaseToChart, failedReleases, nil
}
func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepareOptions, f func() []error) error {
@@ -113,11 +119,25 @@ func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepare
// remain available for the entire operation lifecycle. See issue #1799.
defer r.state.CleanupChartifyTempDirs()
releaseToChart, err := r.prepareChartsIfNeeded(helmfileCommand, dir, opts.Concurrency, opts)
if err != nil {
return err
releaseToChart, failedReleases, prepareErr := r.prepareChartsIfNeeded(helmfileCommand, dir, opts.Concurrency, opts)
// A prepare error with no per-release attribution (state parsing, dependency
// resolution, ...) is a general failure: always abort, even with
// opts.AllowFailedReleases, since there are no processable partial results.
// Otherwise, abort only when partial results are not allowed; the failed
// releases are skipped below and their errors are reported at the end.
if prepareErr != nil && (len(failedReleases) == 0 || !opts.AllowFailedReleases) {
return prepareErr
}
// Releases whose chart preparation failed are removed from the state, so that
// the operation below never executes them against their original, un-prepared
// chart reference. That would either fail again with a duplicate error or,
// worse, bypass chartify modifications (patches, dependencies) and produce an
// unintended result. Their preparation errors are reported via prepareErr.
releases := r.state.Releases
if len(failedReleases) > 0 {
releases = make([]state.ReleaseSpec, 0, len(r.state.Releases))
}
for i := range r.state.Releases {
rel := &r.state.Releases[i]
key := state.PrepareChartKey{
@@ -125,7 +145,10 @@ func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepare
Namespace: rel.Namespace,
KubeContext: rel.KubeContext,
}
if chart := releaseToChart[key]; chart != rel.Chart {
if _, failed := failedReleases[key]; failed {
continue
}
if chart, ok := releaseToChart[key]; ok && chart != rel.Chart {
// The chart has been downloaded and modified by Helmfile (and chartify under the hood).
// We let the later step use the modified version of the chart, located under the `chart` variable,
// instead of the original chart path.
@@ -133,7 +156,11 @@ func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepare
// if it has been modified or not.
rel.ChartPath = chart
}
if len(failedReleases) > 0 {
releases = append(releases, *rel)
}
}
r.state.Releases = releases
r.ReleaseToChart = releaseToChart
@@ -146,8 +173,27 @@ func (r *Run) WithPreparedCharts(helmfileCommand string, opts state.ChartPrepare
}
}
_, err = r.state.TriggerGlobalCleanupEvent(helmfileCommand, firstErr)
return err
_, cleanupErr := r.state.TriggerGlobalCleanupEvent(helmfileCommand, firstErr)
if !opts.AllowFailedReleases {
return cleanupErr
}
// merge the preparation and cleanup errors into a single error output
var merged []error
if prepareErr != nil {
if me, ok := errors.AsType[*MultiError](prepareErr); ok {
merged = append(merged, me.Errors...)
} else {
merged = append(merged, prepareErr)
}
}
if cleanupErr != nil {
merged = append(merged, fmt.Errorf("error during global cleanup event: %w", cleanupErr))
}
if len(merged) > 0 {
return &MultiError{Errors: merged}
}
return nil
}
func (r *Run) Deps(c DepsConfigProvider) []error {
+4 -6
View File
@@ -26,15 +26,13 @@ func runWithLogCapture(t *testing.T, logLevel string, f func(*testing.T, *zap.Su
logFlushed := &sync.WaitGroup{}
// Ensure all the log is consumed into `bs` by calling `logWriter.Close()` followed by `logFlushed.Wait()`
logFlushed.Add(1)
go func() {
logFlushed.Go(func() {
scanner := bufio.NewScanner(logReader)
for scanner.Scan() {
bs.Write(scanner.Bytes())
bs.WriteString("\n")
}
logFlushed.Done()
}()
})
defer func() {
// This is here to avoid data-trace on bytes buffer `bs` to capture logs
@@ -76,7 +74,7 @@ func assertEqualsToSnapshot(t *testing.T, name string, data string) {
"Snapshot file %q does not exist. Rerun this test with `HELMFILE_UPDATE_SNAPSHOT=1 go test -v -run %s %s` to create the snapshot",
snapshotFileName,
t.Name(),
reflect.TypeOf(thisPkgLocator{}).PkgPath(),
reflect.TypeFor[thisPkgLocator]().PkgPath(),
)
}
@@ -87,7 +85,7 @@ func assertEqualsToSnapshot(t *testing.T, name string, data string) {
t.Errorf(
"If you think this is due to the snapshot file being outdated, rerun this test with `HELMFILE_UPDATE_SNAPSHOT=1 go test -v -run %s %s` to update the snapshot",
t.Name(),
reflect.TypeOf(thisPkgLocator{}).PkgPath(),
reflect.TypeFor[thisPkgLocator]().PkgPath(),
)
}
}
+9
View File
@@ -5,8 +5,12 @@ import (
"fmt"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"github.com/helmfile/helmfile/pkg/environment"
"github.com/helmfile/helmfile/pkg/state"
"github.com/helmfile/helmfile/pkg/telemetry"
"github.com/helmfile/helmfile/pkg/tmpl"
)
@@ -33,6 +37,11 @@ func (r *desiredStateLoader) renderTemplatesToYamlWithEnv(baseDir, filename stri
}
func (r *desiredStateLoader) twoPassRenderTemplateToYaml(inherited, overrode *environment.Environment, baseDir, filename string, content []byte) (*bytes.Buffer, error) {
_, span := telemetry.Tracer(telemetry.ScopeHelmfile).Start(r.spanCtx(), "helmfile.render",
trace.WithAttributes(attribute.String("helmfile.state_file", filename)),
)
defer span.End()
var phase string
r.logger.Debugf("%srendering starting for \"%s\": inherited=%v, overrode=%v", phase, filename, inherited, overrode)
+25 -1
View File
@@ -3,6 +3,9 @@ package config
import (
"fmt"
"slices"
"time"
"github.com/spf13/cobra"
)
// ApplyOptoons is the options for the apply command
@@ -93,8 +96,12 @@ type ApplyOptions struct {
TrackTimeout int
// TrackLogs enables log streaming with kubedog
TrackLogs bool
// TrackLogsUntilReady stops pod log streaming when each pod becomes ready. Requires TrackLogs.
TrackLogsUntilReady bool
// TrackFailedLogs streams logs only for pods that enter a failed state.
TrackFailedLogs bool
// TrackLogsInterval is the interval between kubedog log output updates.
TrackLogsInterval time.Duration
// HelmStuckGrace, when > 0, enables the helm-killer safety valve. See
// ReleaseSpec.HelmStuckGrace for details. Value is in seconds.
HelmStuckGrace int
@@ -109,13 +116,14 @@ type ApplyOptions struct {
// NewApply creates a new Apply
func NewApplyOptions() *ApplyOptions {
return &ApplyOptions{}
return &ApplyOptions{TrackLogsInterval: defaultTrackLogsInterval}
}
// ApplyImpl is impl for applyOptions
type ApplyImpl struct {
*GlobalImpl
*ApplyOptions
Cmd *cobra.Command
}
// NewApplyImpl creates a new ApplyImpl
@@ -341,11 +349,24 @@ func (a *ApplyImpl) TrackLogs() bool {
return a.ApplyOptions.TrackLogs
}
// TrackLogsUntilReady returns the track-logs-until-ready flag.
func (a *ApplyImpl) TrackLogsUntilReady() bool {
return a.ApplyOptions.TrackLogsUntilReady
}
// TrackFailedLogs returns the track-failed-logs flag.
func (a *ApplyImpl) TrackFailedLogs() bool {
return a.ApplyOptions.TrackFailedLogs
}
// TrackLogsInterval returns the CLI interval, or zero when the flag was not supplied.
func (a *ApplyImpl) TrackLogsInterval() time.Duration {
if a.Cmd != nil && !a.Cmd.Flags().Changed("track-logs-interval") {
return 0
}
return a.ApplyOptions.TrackLogsInterval
}
// HelmStuckGrace returns the helm-stuck-grace value (seconds, 0 = disabled).
func (a *ApplyImpl) HelmStuckGrace() int {
return a.ApplyOptions.HelmStuckGrace
@@ -371,5 +392,8 @@ func (a *ApplyImpl) ValidateConfig() error {
if a.ApplyOptions.TrackMode != "" && !slices.Contains(validTrackModes, a.ApplyOptions.TrackMode) {
return fmt.Errorf("--track-mode must be 'helm', 'helm-legacy', or 'kubedog', got: %s", a.ApplyOptions.TrackMode)
}
if a.ApplyOptions.TrackLogsInterval < time.Second {
return fmt.Errorf("--track-logs-interval must be at least 1s, got: %s", a.ApplyOptions.TrackLogsInterval)
}
return a.GlobalImpl.ValidateConfig()
}
+56
View File
@@ -0,0 +1,56 @@
package config
import (
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
func TestApplyImpl_TrackLogsInterval(t *testing.T) {
tests := []struct {
name string
setFlag string // explicit --track-logs-interval value; empty means not supplied
noCmd bool // construct ApplyImpl without a Cmd, like non-cobra callers
want time.Duration
}{
{
name: "zero when flag not supplied",
want: 0,
},
{
name: "explicit value returned when flag supplied",
setFlag: "3s",
want: 3 * time.Second,
},
{
name: "explicit default value still counts as supplied",
setFlag: "10s",
want: 10 * time.Second,
},
{
name: "nil cmd falls back to option value",
noCmd: true,
want: defaultTrackLogsInterval,
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
options := NewApplyOptions()
cmd := &cobra.Command{}
cmd.Flags().DurationVar(&options.TrackLogsInterval, "track-logs-interval", options.TrackLogsInterval, "")
if tt.setFlag != "" {
require.NoError(t, cmd.Flags().Set("track-logs-interval", tt.setFlag))
}
impl := NewApplyImpl(nil, options)
if !tt.noCmd {
impl.Cmd = cmd
}
require.Equal(t, tt.want, impl.TrackLogsInterval())
})
}
}
+126 -29
View File
@@ -1,44 +1,141 @@
package config
import (
"fmt"
"regexp"
"strings"
"github.com/helmfile/helmfile/pkg/maputil"
)
// stateValuesSetStringRE matches a single key=value assignment within one
// --state-values-set-string flag value. The value is either a quoted string,
// which may contain commas, or a run of plain or backslash-escaped characters
// ending at the next unescaped comma.
var stateValuesSetStringRE = regexp.MustCompile(`(?s)(?:,|^)([^\s=]+)=(['"][^'"]*['"]|(?:\\.|[^,\\])*\\?)`)
// NewCLIConfigImpl parses the raw --state-values-set-string and
// --state-values-set flag values into state value overrides on g.
func NewCLIConfigImpl(g *GlobalImpl) error {
re := regexp.MustCompile(`(?:,|^)([^\s=]+)=(['"][^'"]*['"]|[^,]+)`)
if err := parseStateValuesSetString(g); err != nil {
return err
}
return parseStateValuesSet(g)
}
// parseStateValuesSetString applies the --state-values-set-string flag values
// to g. Unlike --state-values-set, values are kept as strings without type
// conversion.
func parseStateValuesSetString(g *GlobalImpl) error {
optsSet := g.RawStateValuesSetString()
if len(optsSet) > 0 {
set := map[string]any{}
for i := range optsSet {
ops := re.FindAllStringSubmatch(optsSet[i], -1)
for j := range ops {
op := ops[j]
k := maputil.ParseKey(op[1])
v := op[2]
maputil.Set(set, k, v, true)
}
}
g.SetSet(set)
}
optsSet = g.RawStateValuesSet()
if len(optsSet) > 0 {
set := map[string]any{}
for i := range optsSet {
ops := strings.Split(optsSet[i], ",")
for j := range ops {
op := strings.SplitN(ops[j], "=", 2)
k := maputil.ParseKey(op[0])
v := op[1]
maputil.Set(set, k, v, false)
}
}
g.SetSet(set)
if len(optsSet) == 0 {
return nil
}
set := map[string]any{}
for _, raw := range optsSet {
for _, match := range stateValuesSetStringRE.FindAllStringSubmatch(raw, -1) {
key, value := match[1], match[2]
maputil.Set(set, maputil.ParseKey(key), unescapeCommas(value), true)
}
}
g.SetSet(set)
return nil
}
// parseStateValuesSet applies the --state-values-set flag values to g. Values
// are type-converted like Helm's --set.
func parseStateValuesSet(g *GlobalImpl) error {
optsSet := g.RawStateValuesSet()
if len(optsSet) == 0 {
return nil
}
set := map[string]any{}
for _, raw := range optsSet {
for _, assignment := range splitOnUnescapedCommas(raw) {
if err := setAssignment(set, assignment); err != nil {
return err
}
}
}
g.SetSet(set)
return nil
}
// setAssignment parses a single key=value assignment into set, returning an
// error instead of panicking when the assignment is malformed.
func setAssignment(set map[string]any, assignment string) error {
key, value, found := strings.Cut(unescapeCommas(assignment), "=")
if !found || key == "" {
return fmt.Errorf("--state-values-set: invalid assignment %q: expected <key>=<value>", assignment)
}
maputil.Set(set, maputil.ParseKey(key), value, false)
return nil
}
// splitOnUnescapedCommas splits the input on commas that are not escaped by a
// preceding backslash, keeping every backslash intact so that escapes remain
// available to key and value parsing.
func splitOnUnescapedCommas(input string) []string {
segments := make([]string, 0, 1+strings.Count(input, ","))
var current strings.Builder
escaped := false
for i := range len(input) {
char := input[i]
switch {
case escaped:
current.WriteByte('\\')
current.WriteByte(char)
escaped = false
case char == '\\':
escaped = true
case char == ',':
segments = append(segments, current.String())
current.Reset()
default:
current.WriteByte(char)
}
}
if escaped {
current.WriteByte('\\')
}
return append(segments, current.String())
}
// unescapeCommas turns every backslash-escaped comma into a literal comma,
// preserving all other backslashes, including a trailing one.
func unescapeCommas(input string) string {
if !strings.Contains(input, `\,`) {
return input
}
var out strings.Builder
out.Grow(len(input))
escaped := false
for i := range len(input) {
char := input[i]
switch {
case escaped && char == ',':
out.WriteByte(',')
escaped = false
case escaped:
out.WriteByte('\\')
out.WriteByte(char)
escaped = false
case char == '\\':
escaped = true
default:
out.WriteByte(char)
}
}
if escaped {
out.WriteByte('\\')
}
return out.String()
}
+242
View File
@@ -0,0 +1,242 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCLIConfigImplStateValuesSet(t *testing.T) {
tests := []struct {
name string
set []string
want map[string]any
wantErr string
}{
{
name: "comma separated assignments retain their types",
set: []string{"count=2,enabled=true,disabled=false,empty=null,zero=0,code=001"},
want: map[string]any{
"count": int64(2), "enabled": true, "disabled": false,
"empty": nil, "zero": int64(0), "code": "001",
},
},
{
name: "escaped commas within multiple values",
set: []string{`a=1,b=2\,3,c=9\,10\,11`},
want: map[string]any{"a": int64(1), "b": "2,3", "c": "9,10,11"},
},
{
name: "escaped commas at the start and end of a value",
set: []string{`message=\,hello\,`},
want: map[string]any{"message": ",hello,"},
},
{
name: "repeated flags overwrite earlier values",
set: []string{`message=first\,second`, `message=third\,fourth,count=2`},
want: map[string]any{"message": "third,fourth", "count": int64(2)},
},
{
name: "equals signs remain within values",
set: []string{`message=first\,second=value,count=2`},
want: map[string]any{"message": "first,second=value", "count": int64(2)},
},
{
name: "unicode values",
set: []string{`message=你好\,世界`},
want: map[string]any{"message": "你好,世界"},
},
{
name: "empty value",
set: []string{"message=,count=2"},
want: map[string]any{"message": "", "count": int64(2)},
},
{
name: "backslashes before other characters are preserved",
set: []string{`path=C:\tmp\config`},
want: map[string]any{"path": `C:\tmp\config`},
},
{
name: "trailing backslash is preserved",
set: []string{`path=C:\tmp\`},
want: map[string]any{"path": `C:\tmp\`},
},
{
name: "escaped dots in keys are preserved for key parsing",
set: []string{`annotations.example\.com/key=first\,second`},
want: map[string]any{
"annotations": map[string]any{"example.com/key": "first,second"},
},
},
{
name: "nested array keys",
set: []string{`servers[0].host=east\,west,enabled=true`},
want: map[string]any{
"servers": []any{map[string]any{"host": "east,west"}},
"enabled": true,
},
},
{
name: "even backslashes leave comma as an assignment separator",
set: []string{`path=C:\\,count=2`},
want: map[string]any{"path": `C:\\`, "count": int64(2)},
},
{
name: "odd backslashes escape the comma",
set: []string{`message=first\\\,second`},
want: map[string]any{"message": `first\\,second`},
},
{
name: "assignment without equals sign is rejected",
set: []string{"message"},
wantErr: `invalid assignment "message": expected <key>=<value>`,
},
{
name: "trailing comma is rejected",
set: []string{"message=hello,"},
wantErr: `invalid assignment "": expected <key>=<value>`,
},
{
name: "empty key is rejected",
set: []string{"=hello"},
wantErr: `invalid assignment "=hello": expected <key>=<value>`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGlobalImpl(&GlobalOptions{StateValuesSet: tt.set})
err := NewCLIConfigImpl(g)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, g.StateValuesSet())
})
}
}
func TestNewCLIConfigImplStateValuesSetString(t *testing.T) {
tests := []struct {
name string
set []string
want map[string]any
wantErr string
}{
{
name: "values are kept as strings without type conversion",
set: []string{"count=2,enabled=true"},
want: map[string]any{"count": "2", "enabled": "true"},
},
{
name: "quoted values may contain commas",
set: []string{`zone="zone1,zone2",imageTag=1.23.3`},
want: map[string]any{"zone": `"zone1,zone2"`, "imageTag": "1.23.3"},
},
{
name: "escaped commas within values",
set: []string{`zone=zone1\,zone2,imageTag=1.23.3`},
want: map[string]any{"zone": "zone1,zone2", "imageTag": "1.23.3"},
},
{
name: "backslashes and trailing backslash are preserved",
set: []string{`path=C:\tmp\config,trailing=C:\tmp\`},
want: map[string]any{"path": `C:\tmp\config`, "trailing": `C:\tmp\`},
},
{
name: "even backslashes leave comma as an assignment separator",
set: []string{`path=C:\\,count=2`},
want: map[string]any{"path": `C:\\`, "count": "2"},
},
{
name: "empty value",
set: []string{"message=,count=2"},
want: map[string]any{"message": "", "count": "2"},
},
{
name: "repeated flags overwrite earlier values",
set: []string{`zone=a\,b`, `zone=c\,d`},
want: map[string]any{"zone": "c,d"},
},
{
name: "escaped dots in keys are preserved for key parsing",
set: []string{`annotations.example\.com/key=first\,second`},
want: map[string]any{
"annotations": map[string]any{"example.com/key": "first,second"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewGlobalImpl(&GlobalOptions{StateValuesSetString: tt.set})
err := NewCLIConfigImpl(g)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, g.StateValuesSet())
})
}
}
func TestNewCLIConfigImplBothSetFlags(t *testing.T) {
g := NewGlobalImpl(&GlobalOptions{
StateValuesSet: []string{"count=2"},
StateValuesSetString: []string{"imageTag=1.23.3"},
})
require.NoError(t, NewCLIConfigImpl(g))
want := map[string]any{
"count": int64(2),
"imageTag": "1.23.3",
}
assert.Equal(t, want, g.StateValuesSet())
}
func TestSplitOnUnescapedCommas(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "no commas", input: "a=1", want: []string{"a=1"}},
{name: "plain separators", input: "a=1,b=2", want: []string{"a=1", "b=2"}},
{name: "escaped separator", input: `a=1\,2,b=3`, want: []string{`a=1\,2`, "b=3"}},
{name: "escaped separator only", input: `a=1\,2`, want: []string{`a=1\,2`}},
{name: "even backslashes unescape the separator", input: `a=1\\,b=2`, want: []string{`a=1\\`, "b=2"}},
{name: "trailing separator", input: "a=1,", want: []string{"a=1", ""}},
{name: "trailing backslash", input: `a=1\`, want: []string{`a=1\`}},
{name: "unicode", input: `a=你好\,世界,b=2`, want: []string{`a=你好\,世界`, "b=2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, splitOnUnescapedCommas(tt.input))
})
}
}
func TestUnescapeCommas(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{name: "no escapes", input: "a=1,b=2", want: "a=1,b=2"},
{name: "escaped comma", input: `a=1\,2`, want: "a=1,2"},
{name: "escaped dot preserved", input: `a\.b=1`, want: `a\.b=1`},
{name: "even backslashes preserved", input: `a=1\\`, want: `a=1\\`},
{name: "trailing backslash preserved", input: `a=1\`, want: `a=1\`},
{name: "unicode preserved", input: `a=你好\,世界`, want: "a=你好,世界"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, unescapeCommas(tt.input))
})
}
}
+18
View File
@@ -35,6 +35,8 @@ type GlobalOptions struct {
SkipDeps bool
// SkipRefresh is true if the running "helm repo update" should be skipped
SkipRefresh bool
// AllowFailedReleases is true if partial errors during release processing are allowed
AllowFailedReleases bool
// StripArgsValuesOnExitError is true if the ARGS output on exit error should be suppressed
StripArgsValuesOnExitError bool
// DisableForceUpdate is true if force updating repos is not desirable when executing "helm repo add" (Helm 3)
@@ -82,6 +84,8 @@ type GlobalOptions struct {
LogOutput io.Writer
// SequentialHelmfiles is true if helmfile.d files should be processed sequentially instead of in parallel.
SequentialHelmfiles bool
// OtelTracing is true if OpenTelemetry tracing should be enabled for this run.
OtelTracing bool
}
// Logger returns the logger to use.
@@ -264,6 +268,11 @@ func (g *GlobalImpl) SkipRefresh() bool {
return g.GlobalOptions.SkipRefresh
}
// AllowFailedReleases returns true if partial errors during release processing are allowed
func (g *GlobalImpl) AllowFailedReleases() bool {
return g.GlobalOptions.AllowFailedReleases
}
// StripArgsValuesOnExitError return if the ARGS output on exit error should be suppressed
func (g *GlobalImpl) StripArgsValuesOnExitError() bool {
return g.GlobalOptions.StripArgsValuesOnExitError
@@ -376,6 +385,15 @@ func (g *GlobalImpl) Interactive() bool {
return os.Getenv(envvar.Interactive) == "true"
}
// OtelTracing returns true if OpenTelemetry tracing is enabled via the
// --otel-tracing flag or the HELMFILE_OTEL_TRACING environment variable.
func (g *GlobalImpl) OtelTracing() bool {
if g.GlobalOptions.OtelTracing {
return true
}
return os.Getenv(envvar.OtelTracing) == "true"
}
// Args returns the args to use for helm
func (g *GlobalImpl) Args() string {
args := g.GlobalOptions.Args
+30 -1
View File
@@ -3,8 +3,16 @@ package config
import (
"fmt"
"slices"
"time"
"github.com/spf13/cobra"
)
// defaultTrackLogsInterval is the default --track-logs-interval shared by
// the sync and apply commands. It mirrors kubedog's defaultLogsInterval;
// keep the two in sync.
const defaultTrackLogsInterval = 10 * time.Second
// SyncOptions is the options for the build command
type SyncOptions struct {
// Set is the set flag
@@ -61,8 +69,12 @@ type SyncOptions struct {
TrackTimeout int
// TrackLogs enables log streaming with kubedog
TrackLogs bool
// TrackLogsUntilReady stops pod log streaming when each pod becomes ready. Requires TrackLogs.
TrackLogsUntilReady bool
// TrackFailedLogs streams logs only for pods that enter a failed state.
TrackFailedLogs bool
// TrackLogsInterval is the interval between kubedog log output updates.
TrackLogsInterval time.Duration
// HelmStuckGrace, when > 0, enables the helm-killer safety valve. See
// ReleaseSpec.HelmStuckGrace for details. Value is in seconds.
HelmStuckGrace int
@@ -93,13 +105,14 @@ type SyncOptions struct {
// NewSyncOptions creates a new Apply
func NewSyncOptions() *SyncOptions {
return &SyncOptions{}
return &SyncOptions{TrackLogsInterval: defaultTrackLogsInterval}
}
// SyncImpl is impl for applyOptions
type SyncImpl struct {
*GlobalImpl
*SyncOptions
Cmd *cobra.Command
}
// NewSyncImpl creates a new SyncImpl
@@ -249,11 +262,24 @@ func (t *SyncImpl) TrackLogs() bool {
return t.SyncOptions.TrackLogs
}
// TrackLogsUntilReady returns the track-logs-until-ready flag.
func (t *SyncImpl) TrackLogsUntilReady() bool {
return t.SyncOptions.TrackLogsUntilReady
}
// TrackFailedLogs returns the track-failed-logs flag.
func (t *SyncImpl) TrackFailedLogs() bool {
return t.SyncOptions.TrackFailedLogs
}
// TrackLogsInterval returns the CLI interval, or zero when the flag was not supplied.
func (t *SyncImpl) TrackLogsInterval() time.Duration {
if t.Cmd != nil && !t.Cmd.Flags().Changed("track-logs-interval") {
return 0
}
return t.SyncOptions.TrackLogsInterval
}
// HelmStuckGrace returns the helm-stuck-grace value (seconds, 0 = disabled).
func (t *SyncImpl) HelmStuckGrace() int {
return t.SyncOptions.HelmStuckGrace
@@ -349,5 +375,8 @@ func (t *SyncImpl) ValidateConfig() error {
if t.SyncOptions.TrackMode != "" && !slices.Contains(validTrackModes, t.SyncOptions.TrackMode) {
return fmt.Errorf("--track-mode must be 'helm', 'helm-legacy', or 'kubedog', got: %s", t.SyncOptions.TrackMode)
}
if t.SyncOptions.TrackLogsInterval < time.Second {
return fmt.Errorf("--track-logs-interval must be at least 1s, got: %s", t.SyncOptions.TrackLogsInterval)
}
return t.GlobalImpl.ValidateConfig()
}
+56
View File
@@ -0,0 +1,56 @@
package config
import (
"testing"
"time"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
func TestSyncImpl_TrackLogsInterval(t *testing.T) {
tests := []struct {
name string
setFlag string // explicit --track-logs-interval value; empty means not supplied
noCmd bool // construct SyncImpl without a Cmd, like non-cobra callers
want time.Duration
}{
{
name: "zero when flag not supplied",
want: 0,
},
{
name: "explicit value returned when flag supplied",
setFlag: "3s",
want: 3 * time.Second,
},
{
name: "explicit default value still counts as supplied",
setFlag: "10s",
want: 10 * time.Second,
},
{
name: "nil cmd falls back to option value",
noCmd: true,
want: defaultTrackLogsInterval,
},
}
for i := range tests {
tt := tests[i]
t.Run(tt.name, func(t *testing.T) {
options := NewSyncOptions()
cmd := &cobra.Command{}
cmd.Flags().DurationVar(&options.TrackLogsInterval, "track-logs-interval", options.TrackLogsInterval, "")
if tt.setFlag != "" {
require.NoError(t, cmd.Flags().Set("track-logs-interval", tt.setFlag))
}
impl := NewSyncImpl(nil, options)
if !tt.noCmd {
impl.Cmd = cmd
}
require.Equal(t, tt.want, impl.TrackLogsInterval())
})
}
}
+61
View File
@@ -0,0 +1,61 @@
package config
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestTrackLogsIntervalDefault(t *testing.T) {
require.Equal(t, 10*time.Second, NewApplyOptions().TrackLogsInterval)
require.Equal(t, 10*time.Second, NewSyncOptions().TrackLogsInterval)
}
func TestTrackLogsIntervalValidation(t *testing.T) {
commands := []struct {
name string
validate func(time.Duration) error
}{
{
name: "apply",
validate: func(interval time.Duration) error {
options := NewApplyOptions()
options.TrackLogsInterval = interval
return NewApplyImpl(NewGlobalImpl(&GlobalOptions{}), options).ValidateConfig()
},
},
{
name: "sync",
validate: func(interval time.Duration) error {
options := NewSyncOptions()
options.TrackLogsInterval = interval
return NewSyncImpl(NewGlobalImpl(&GlobalOptions{}), options).ValidateConfig()
},
},
}
for _, command := range commands {
t.Run(command.name, func(t *testing.T) {
for _, tt := range []struct {
name string
interval time.Duration
wantErr bool
}{
{name: "zero", interval: 0, wantErr: true},
{name: "negative", interval: -time.Second, wantErr: true},
{name: "below minimum", interval: 500 * time.Millisecond, wantErr: true},
{name: "minimum", interval: time.Second},
} {
t.Run(tt.name, func(t *testing.T) {
err := command.validate(tt.interval)
if tt.wantErr {
require.ErrorContains(t, err, "--track-logs-interval must be at least 1s")
} else {
require.NoError(t, err)
}
})
}
})
}
}
+6
View File
@@ -4,6 +4,10 @@ const (
DisableInsecureFeatures = "HELMFILE_DISABLE_INSECURE_FEATURES"
DisableInsecureTemplateFunctions = "HELMFILE_DISABLE_INSECURE_TEMPLATE_FUNCTIONS"
DisableHooks = "HELMFILE_DISABLE_HOOKS"
// DisableVals passes `ref+` values through unchanged for external vals processing
DisableVals = "HELMFILE_DISABLE_VALS"
// DisableValsStrict errors when any `ref+` value is detected
DisableValsStrict = "HELMFILE_DISABLE_VALS_STRICT"
// use helm status to check if a release exists before installing it
UseHelmStatusToCheckReleaseExistence = "HELMFILE_USE_HELM_STATUS_TO_CHECK_RELEASE_EXISTENCE"
@@ -26,6 +30,8 @@ const (
CacheHome = "HELMFILE_CACHE_HOME"
Interactive = "HELMFILE_INTERACTIVE"
RepoRetry = "HELMFILE_REPO_RETRIES"
OtelTracing = "HELMFILE_OTEL_TRACING"
OtelMetricsPerRelease = "HELMFILE_OTEL_METRICS_PER_RELEASE"
RenderYaml = "HELMFILE_RENDER_YAML" // force helmfile.yaml to be rendered as template regardless of extension, expecting "true" lower case
// AWSSDKLogLevel controls AWS SDK logging level
+149 -71
View File
@@ -3,16 +3,21 @@ package event
import (
goContext "context"
"fmt"
"maps"
"os"
"strconv"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/environment"
"github.com/helmfile/helmfile/pkg/envvar"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
"github.com/helmfile/helmfile/pkg/telemetry"
"github.com/helmfile/helmfile/pkg/tmpl"
)
@@ -34,6 +39,12 @@ type Bus struct {
Runner helmexec.Runner
Hooks []Hook
// Ctx, when set, is used by the lazily-constructed default Runner so that
// hook subprocesses join the current trace. It should carry trace context
// without cancellation (see the WithoutCancel bridge in pkg/state); nil
// falls back to context.TODO(), the historical behavior.
Ctx goContext.Context
BasePath string
StateFilePath string
Namespace string
@@ -59,12 +70,16 @@ func (bus *Bus) Trigger(evt string, evtErr error, context map[string]any) (bool,
}
if bus.Runner == nil {
ctx := bus.Ctx
if ctx == nil {
// It would be better to pass app.Ctx here, but it requires a lot of work.
// It seems that this code only for running hooks, which took not to long time as helm.
ctx = goContext.TODO()
}
bus.Runner = helmexec.ShellRunner{
Dir: bus.BasePath,
Logger: bus.Logger,
// It would be better to pass app.Ctx here, but it requires a lot of work.
// It seems that this code only for running hooks, which took not to long time as helm.
Ctx: goContext.TODO(),
Ctx: ctx,
}
}
@@ -79,77 +94,140 @@ func (bus *Bus) Trigger(evt string, evtErr error, context map[string]any) (bool,
continue
}
var err error
name := hook.Name
if name == "" {
if hook.Kubectl != nil {
name = "kubectlApply"
} else {
name = hook.Command
}
}
if hook.Kubectl != nil {
if hook.Command != "" {
bus.Logger.Warnf("warn: ignoring command '%s' given within a kubectlApply hook", hook.Command)
}
hook.Command = "kubectl"
if val, found := hook.Kubectl["filename"]; found {
if _, found := hook.Kubectl["kustomize"]; found {
return false, fmt.Errorf("hook[%s]: kustomize & filename cannot be used together", name)
}
hook.Args = append([]string{"apply", "-f"}, val)
} else if val, found := hook.Kubectl["kustomize"]; found {
hook.Args = append([]string{"apply", "-k"}, val)
} else {
return false, fmt.Errorf("hook[%s]: either kustomize or filename must be given", name)
}
}
bus.Logger.Debugf("hook[%s]: stateFilePath=%s, basePath=%s\n", name, bus.StateFilePath, bus.BasePath)
data := map[string]any{
"Environment": bus.Env,
"Namespace": bus.Namespace,
"Event": event{
Name: evt,
Error: evtErr,
},
}
for k, v := range context {
data[k] = v
}
render := tmpl.NewTextRenderer(bus.Fs, bus.BasePath, data)
bus.Logger.Debugf("hook[%s]: triggered by event \"%s\"\n", name, evt)
command, err := render.RenderTemplateText(hook.Command)
hookExecuted, err := bus.runHook(hook, evt, evtErr, context)
if err != nil {
return false, fmt.Errorf("hook[%s]: %v", name, err)
return false, err
}
args := make([]string, len(hook.Args))
for i, raw := range hook.Args {
args[i], err = render.RenderTemplateText(raw)
if err != nil {
return false, fmt.Errorf("hook[%s]: %v", name, err)
}
}
bytes, err := bus.Runner.Execute(command, args, map[string]string{}, false)
bus.Logger.Debugf("hook[%s]: %s\n", name, string(bytes))
if hook.ShowLogs {
prefix := fmt.Sprintf("\nhook[%s] logs | ", evt)
bus.Logger.Infow(prefix + strings.ReplaceAll(string(bytes), "\n", prefix))
}
if err != nil {
return false, fmt.Errorf("hook[%s]: command `%s` failed: %v", name, command, err)
}
executed = true
executed = executed || hookExecuted
}
return executed, nil
}
// runHook renders and executes a single hook; the returned bool reports
// whether the hook ran. The whole hook execution is wrapped in one
// helmfile.hook span (a no-op when telemetry is disabled), and the hook's
// subprocess span nests under it via hookRunner.
func (bus *Bus) runHook(hook Hook, evt string, evtErr error, context map[string]any) (executed bool, err error) {
name := hook.Name
if name == "" {
if hook.Kubectl != nil {
name = "kubectlApply"
} else {
name = hook.Command
}
}
hookCtx, span := telemetry.Tracer(telemetry.ScopeHelmfile).Start(bus.spanParent(), "helmfile.hook",
trace.WithAttributes(
attribute.String("hook.event", evt),
attribute.String("hook.name", name),
),
)
defer func() {
if err != nil {
// The raw error embeds the rendered command (which may contain
// templated credentials) and the runner's detailed exit error;
// keep the span description generic.
span.SetStatus(codes.Error, "hook failed")
}
span.End()
}()
if hook.Kubectl != nil {
if err := bus.prepareKubectlHook(&hook, name); err != nil {
return false, err
}
}
bus.Logger.Debugf("hook[%s]: stateFilePath=%s, basePath=%s\n", name, bus.StateFilePath, bus.BasePath)
data := map[string]any{
"Environment": bus.Env,
"Namespace": bus.Namespace,
"Event": event{
Name: evt,
Error: evtErr,
},
}
maps.Copy(data, context)
render := tmpl.NewTextRenderer(bus.Fs, bus.BasePath, data)
bus.Logger.Debugf("hook[%s]: triggered by event \"%s\"\n", name, evt)
command, err := render.RenderTemplateText(hook.Command)
if err != nil {
return false, fmt.Errorf("hook[%s]: %v", name, err)
}
args := make([]string, len(hook.Args))
for i, raw := range hook.Args {
args[i], err = render.RenderTemplateText(raw)
if err != nil {
return false, fmt.Errorf("hook[%s]: %v", name, err)
}
}
bytes, err := bus.hookRunner(hookCtx).Execute(command, args, map[string]string{}, false)
bus.Logger.Debugf("hook[%s]: %s\n", name, string(bytes))
if hook.ShowLogs {
prefix := fmt.Sprintf("\nhook[%s] logs | ", evt)
bus.Logger.Infow(prefix + strings.ReplaceAll(string(bytes), "\n", prefix))
}
if err != nil {
return false, fmt.Errorf("hook[%s]: command `%s` failed: %v", name, command, err)
}
return true, nil
}
// prepareKubectlHook rewrites a kubectlApply hook into the equivalent
// explicit kubectl command, rejecting invalid configurations.
func (bus *Bus) prepareKubectlHook(hook *Hook, name string) error {
if hook.Command != "" {
bus.Logger.Warnf("warn: ignoring command '%s' given within a kubectlApply hook", hook.Command)
}
hook.Command = "kubectl"
if val, found := hook.Kubectl["filename"]; found {
if _, found := hook.Kubectl["kustomize"]; found {
return fmt.Errorf("hook[%s]: kustomize & filename cannot be used together", name)
}
hook.Args = append([]string{"apply", "-f"}, val)
return nil
}
if val, found := hook.Kubectl["kustomize"]; found {
hook.Args = append([]string{"apply", "-k"}, val)
return nil
}
return fmt.Errorf("hook[%s]: either kustomize or filename must be given", name)
}
// spanParent returns the context hook spans attach to.
func (bus *Bus) spanParent() goContext.Context {
if bus.Ctx != nil {
return bus.Ctx
}
return goContext.Background()
}
// hookRunner returns a runner whose context is the hook span's, so the
// subprocess span started inside ShellRunner nests under the hook span. The
// cancellation semantics are unchanged: hookCtx derives from Bus.Ctx, which
// by contract never carries cancellation. Non-ShellRunner runners (test
// fakes) are returned unchanged.
func (bus *Bus) hookRunner(hookCtx goContext.Context) helmexec.Runner {
switch r := bus.Runner.(type) {
case *helmexec.ShellRunner:
clone := *r
clone.Ctx = hookCtx
return &clone
case helmexec.ShellRunner:
clone := r
clone.Ctx = hookCtx
return clone
default:
return bus.Runner
}
}
+113
View File
@@ -0,0 +1,113 @@
package event
import (
goContext "context"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
v1 "go.opentelemetry.io/proto/otlp/trace/v1"
"go.uber.org/zap"
"github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/telemetry"
"github.com/helmfile/helmfile/pkg/telemetry/otlptest"
)
// TestHookSpanExported runs one hook through the default ShellRunner with
// telemetry enabled and asserts the helmfile.hook span and its child os.exec
// span (proving the hook's subprocess nests under the hook span, not the
// command span directly).
func TestHookSpanExported(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses the unix true binary")
}
rec := otlptest.NewRecorder(t)
otlptest.SetupTelemetry(t, rec, "helmfile test")
bus := &Bus{
Hooks: []Hook{
{
Name: "migrate",
Events: []string{"presync"},
Command: "true",
},
{
Name: "other-event-hook",
Events: []string{"postsync"},
Command: "true",
},
},
Logger: zap.NewNop().Sugar(),
Fs: filesystem.DefaultFileSystem(),
Ctx: goContext.WithoutCancel(telemetry.CommandContext()),
}
executed, err := bus.Trigger("presync", nil, nil)
require.NoError(t, err)
assert.True(t, executed, "the presync hook should have run")
otlptest.ShutdownTelemetry(t)
spans := rec.Spans(t)
hook := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "helmfile.hook" }, "hook span")
evt, ok := otlptest.AttrString(hook, "hook.event")
require.True(t, ok)
assert.Equal(t, "presync", evt)
name, ok := otlptest.AttrString(hook, "hook.name")
require.True(t, ok)
assert.Equal(t, "migrate", name)
// Exactly one hook span: the postsync hook must not run for presync.
for _, s := range spans {
if s.Name == "helmfile.hook" {
id, _ := otlptest.AttrString(s, "hook.name")
assert.Equal(t, "migrate", id, "only the matching hook should produce a span")
}
}
// The hook's subprocess span nests under the hook span.
exec := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "os.exec" }, "hook exec span")
assert.Equal(t, hook.TraceId, exec.TraceId)
assert.Equal(t, hook.SpanId, exec.ParentSpanId, "os.exec span must nest under the helmfile.hook span")
}
// TestHookSpanFailureMessageIsGeneric runs a failing hook and asserts the
// span's error status does not embed the rendered command or runner details.
func TestHookSpanFailureMessageIsGeneric(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses the unix false binary")
}
rec := otlptest.NewRecorder(t)
otlptest.SetupTelemetry(t, rec, "helmfile test")
bus := &Bus{
Hooks: []Hook{
{
Name: "fail",
Events: []string{"presync"},
Command: "false",
Args: []string{"--secret", "hunter2"},
},
},
Logger: zap.NewNop().Sugar(),
Fs: filesystem.DefaultFileSystem(),
Ctx: goContext.WithoutCancel(telemetry.CommandContext()),
}
_, err := bus.Trigger("presync", nil, nil)
require.Error(t, err)
otlptest.ShutdownTelemetry(t)
hook := otlptest.FindSpanWhere(t, rec.Spans(t), func(s *v1.Span) bool { return s.Name == "helmfile.hook" }, "hook span")
require.NotNil(t, hook.Status)
assert.Equal(t, v1.Status_STATUS_CODE_ERROR, hook.Status.Code)
assert.Equal(t, "hook failed", hook.Status.Message)
assert.NotContains(t, hook.Status.Message, "false")
assert.NotContains(t, hook.Status.Message, "hunter2")
}
+26
View File
@@ -1,6 +1,7 @@
package event
import (
goContext "context"
"errors"
"fmt"
"io"
@@ -13,6 +14,7 @@ import (
"github.com/helmfile/helmfile/pkg/environment"
ffs "github.com/helmfile/helmfile/pkg/filesystem"
"github.com/helmfile/helmfile/pkg/helmexec"
)
type runner struct {
@@ -343,3 +345,27 @@ func TestTriggerCleanupEventWithNilError(t *testing.T) {
t.Errorf("expected arg %q, got %q", expectedArg, call.args[0])
}
}
func TestBusDefaultRunnerUsesCtxWhenSet(t *testing.T) {
ctx := t.Context()
bus := &Bus{Ctx: ctx, Logger: zap.NewNop().Sugar()}
_, err := bus.Trigger("presync", nil, nil)
require.NoError(t, err)
runner, ok := bus.Runner.(helmexec.ShellRunner)
require.True(t, ok, "default runner should be a ShellRunner")
require.Equal(t, ctx, runner.Ctx, "default runner must use Bus.Ctx when set")
}
func TestBusDefaultRunnerFallsBackToTODO(t *testing.T) {
bus := &Bus{Logger: zap.NewNop().Sugar()}
_, err := bus.Trigger("presync", nil, nil)
require.NoError(t, err)
runner, ok := bus.Runner.(helmexec.ShellRunner)
require.True(t, ok)
require.Equal(t, goContext.TODO(), runner.Ctx, "nil Bus.Ctx must preserve the historical TODO context")
}
+16
View File
@@ -50,6 +50,10 @@ type Helm struct {
UpdateDepsCallbacks map[string]func(string) error
// ShowChartWithFlagsFunc lets tests stub `helm show chart <ref> --version <constraint>`,
// which state.HelmState uses to resolve OCI constraint versions before caching.
ShowChartWithFlagsFunc func(chartPath string, flags ...string) (chart.Metadata, error)
DiffMutex *sync.Mutex
ChartsMutex *sync.Mutex
ReleasesMutex *sync.Mutex
@@ -332,6 +336,18 @@ func (helm *Helm) ShowChart(chartPath string) (chart.Metadata, error) {
}
}
// ShowChartWithFlags mimics `helm show chart <ref> --version <constraint>`
// resolution used by state.HelmState to convert a version constraint to a
// concrete semver before caching. Test cases can inject a fake resolver via
// helm.ShowChartWithFlagsFunc; the default falls back to ShowChart to keep
// unrelated tests working.
func (helm *Helm) ShowChartWithFlags(chartPath string, flags ...string) (chart.Metadata, error) {
if helm.ShowChartWithFlagsFunc != nil {
return helm.ShowChartWithFlagsFunc(chartPath, flags...)
}
return helm.ShowChart(chartPath)
}
// IsHelm4Enabled detects the installed Helm version by executing the helm binary.
// It returns true if Helm 4.x is installed, false for Helm 3.x or earlier.
// Falls back to environment variable HELMFILE_HELM4 if helm binary is not available.
+2 -3
View File
@@ -3,6 +3,7 @@ package hcllang
import (
nativejson "encoding/json"
"fmt"
"maps"
"slices"
"strings"
@@ -49,9 +50,7 @@ func ctyMergeValues(a, b cty.Value) cty.Value {
(a.Type().IsMapType() && b.Type().IsMapType()) {
mergedAttrs := make(map[string]cty.Value)
// Start with all attrs from a.
for name, av := range a.AsValueMap() {
mergedAttrs[name] = av
}
maps.Copy(mergedAttrs, a.AsValueMap())
// Overlay attrs from b.
for name, bv := range b.AsValueMap() {
if av, ok := mergedAttrs[name]; ok {
+9
View File
@@ -1,6 +1,7 @@
package helmexec
import (
"context"
"io"
)
@@ -8,4 +9,12 @@ type HelmContext struct {
HistoryMax int
WorkerIndex int
Writer io.Writer
// Ctx, when set, carries the per-release span context so that helm
// subprocesses started for this release nest under the release span
// (consumed by the execer's execWithContext funnel; nil falls back to the
// runner's own context, i.e. the historical behavior). Cancellation
// semantics are unchanged either way: callers derive it from the app
// context, which is where the runner's context comes from too.
Ctx context.Context
}
+85 -58
View File
@@ -181,12 +181,20 @@ func GetPluginVersion(name, pluginsDir string) (*semver.Version, error) {
return nil, fmt.Errorf("plugin %s not installed", name)
}
func redactedURL(chart string) string {
chartURL, err := url.ParseRequestURI(chart)
// RedactedURL returns ref with any password in the URL userinfo replaced,
// matching helmfile's log redaction (url.URL.Redacted). Non-URL strings are
// returned unchanged. Telemetry reuses it so span attributes are sanitized at
// least as strictly as log output.
func RedactedURL(ref string) string {
refURL, err := url.ParseRequestURI(ref)
if err != nil {
return chart
return ref
}
return chartURL.Redacted()
return refURL.Redacted()
}
func redactedURL(chart string) string {
return RedactedURL(chart)
}
// New for running helm commands
@@ -298,14 +306,8 @@ func (helm *execer) retryRepoOp(name string, op func() ([]byte, error)) ([]byte,
}
// Cap the shift exponent at 5 (2^5 = 32x already exceeds the 30x cap)
// so very large --repo-retries values can't overflow time.Duration.
shift := attempt
if shift > 5 {
shift = 5
}
backoff := repoRetryBaseBackoff * time.Duration(1<<shift)
if backoff > 30*repoRetryBaseBackoff {
backoff = 30 * repoRetryBaseBackoff
}
shift := min(attempt, 5)
backoff := min(repoRetryBaseBackoff*time.Duration(1<<shift), 30*repoRetryBaseBackoff)
helm.logger.Warnf("repo operation %q failed (%s); retry %d/%d in %v",
name, conciseError(err), attempt+1, maxRetries, backoff)
// sleepCtx returns false if interrupted by context cancellation; in that
@@ -321,8 +323,7 @@ func (helm *execer) retryRepoOp(name string, op func() ([]byte, error)) ([]byte,
// reports just the exit status, avoiding the very verbose PATH/ARGS/OUTPUT
// dump that Error() produces.
func conciseError(err error) string {
var ee ExitError
if errors.As(err, &ee) {
if ee, ok := errors.AsType[ExitError](err); ok {
return fmt.Sprintf("exit status %d", ee.ExitStatus())
}
return err.Error()
@@ -415,7 +416,7 @@ func (helm *execer) AddRepo(name, repository, cafile, certfile, keyfile, usernam
fmt.Fprintf(&buffer, "%s\n", password)
return helm.execStdIn(args, map[string]string{}, &buffer)
}
return helm.exec(args, map[string]string{}, nil)
return helm.exec(args, map[string]string{})
})
default:
helm.logger.Errorf("ERROR: unknown type '%v' for repository %v", managed, name)
@@ -434,7 +435,7 @@ func (helm *execer) UpdateRepo() error {
helm.extra = savedExtra
}()
out, err := helm.retryRepoOp("update", func() ([]byte, error) {
return helm.exec([]string{"repo", "update"}, map[string]string{}, nil)
return helm.exec([]string{"repo", "update"}, map[string]string{})
})
helm.info(out)
return err
@@ -507,8 +508,7 @@ func getSupportedDependencyFlags() map[string]bool {
// Get global flags from Helm 3 cli.EnvSettings
envSettings := cliv3.New()
envType := reflect.TypeOf(*envSettings)
for i := 0; i < envType.NumField(); i++ {
field := envType.Field(i)
for field := range envType.Fields() {
if field.IsExported() {
flagName := "--" + toKebabCase(field.Name)
supported[flagName] = true
@@ -521,8 +521,7 @@ func getSupportedDependencyFlags() map[string]bool {
// Get dependency-specific flags from Helm 3 action.Dependency
dep := actionv3.NewDependency()
depType := reflect.TypeOf(*dep)
for i := 0; i < depType.NumField(); i++ {
field := depType.Field(i)
for field := range depType.Fields() {
if field.IsExported() {
flagName := "--" + toKebabCase(field.Name)
supported[flagName] = true
@@ -532,8 +531,7 @@ func getSupportedDependencyFlags() map[string]bool {
// Get global flags from Helm 4 cli.EnvSettings
envSettings := cliv4.New()
envType := reflect.TypeOf(*envSettings)
for i := 0; i < envType.NumField(); i++ {
field := envType.Field(i)
for field := range envType.Fields() {
if field.IsExported() {
flagName := "--" + toKebabCase(field.Name)
supported[flagName] = true
@@ -546,8 +544,7 @@ func getSupportedDependencyFlags() map[string]bool {
// Get dependency-specific flags from Helm 4 action.Dependency
dep := actionv4.NewDependency()
depType := reflect.TypeOf(*dep)
for i := 0; i < depType.NumField(); i++ {
field := depType.Field(i)
for field := range depType.Fields() {
if field.IsExported() {
flagName := "--" + toKebabCase(field.Name)
supported[flagName] = true
@@ -581,8 +578,8 @@ func filterDependencyUnsupportedFlags(flags []string) []string {
for _, flag := range flags {
// Extract flag name without value (e.g., "--dry-run=server" -> "--dry-run")
flagName := flag
if idx := strings.Index(flag, "="); idx != -1 {
flagName = flag[:idx]
if before, _, ok := strings.Cut(flag, "="); ok {
flagName = before
}
// Check if this flag or any prefix of it is supported
@@ -624,7 +621,7 @@ func (helm *execer) BuildDeps(name, chart string, flags ...string) error {
args = append(args, "--plain-http")
}
out, err := helm.exec(args, map[string]string{}, nil)
out, err := helm.exec(args, map[string]string{})
helm.info(out)
return err
}
@@ -646,7 +643,7 @@ func (helm *execer) UpdateDeps(chart string) error {
args = append(args, "--plain-http")
}
out, err := helm.exec(args, map[string]string{}, nil)
out, err := helm.exec(args, map[string]string{})
helm.info(out)
return err
}
@@ -658,7 +655,7 @@ func (helm *execer) SyncRelease(context HelmContext, name, chart, namespace stri
flags = append(flags, "--history-max", strconv.Itoa(context.HistoryMax))
out, err := helm.exec(append(append(preArgs, "upgrade", "--install", name, chart), flags...), env, nil)
out, err := helm.execWithContext(context.Ctx, append(append(preArgs, "upgrade", "--install", name, chart), flags...), env, nil)
helm.info(out)
return err
}
@@ -667,7 +664,7 @@ func (helm *execer) ReleaseStatus(context HelmContext, name string, flags ...str
helm.logger.Infof("Getting status %v", name)
preArgs := make([]string, 0)
env := make(map[string]string)
out, err := helm.exec(append(append(preArgs, "status", name), flags...), env, nil)
out, err := helm.execWithContext(context.Ctx, append(append(preArgs, "status", name), flags...), env, nil)
helm.info(out)
return err
}
@@ -679,7 +676,7 @@ func (helm *execer) List(context HelmContext, filter string, flags ...string) (s
args := []string{"list", "--filter", filter}
enableLiveOutput := false
out, err := helm.exec(append(append(preArgs, args...), flags...), env, &enableLiveOutput)
out, err := helm.execWithContext(context.Ctx, append(append(preArgs, args...), flags...), env, &enableLiveOutput)
// In v2 we have been expecting `helm list FILTER` prints nothing.
// In v3 helm still prints the header like `NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION`,
// which confuses helmfile's existing logic that treats any non-empty output from `helm list` is considered as the indication
@@ -734,7 +731,7 @@ func (helm *execer) DecryptSecret(context HelmContext, name string, flags ...str
secretArg = "decrypt"
}
enableLiveOutput := false
secretBytes, err := helm.exec(append(append(preArgs, "secrets", secretArg, absPath), flags...), env, &enableLiveOutput)
secretBytes, err := helm.execWithContext(context.Ctx, append(append(preArgs, "secrets", secretArg, absPath), flags...), env, &enableLiveOutput)
if err != nil {
secret.err = err
return "", err
@@ -833,8 +830,8 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
i++
continue
}
if strings.HasPrefix(flags[i], "--output-dir=") {
outputDir = strings.TrimPrefix(flags[i], "--output-dir=")
if after, ok := strings.CutPrefix(flags[i], "--output-dir="); ok {
outputDir = after
continue
}
filteredFlags = append(filteredFlags, flags[i])
@@ -844,7 +841,7 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
return fmt.Errorf("output dir not found for template command")
}
out, err := helm.exec(append(args, filteredFlags...), map[string]string{}, nil)
out, err := helm.exec(append(args, filteredFlags...), map[string]string{})
if err != nil {
return err
}
@@ -876,7 +873,7 @@ func (helm *execer) TemplateRelease(name string, chart string, flags ...string)
return nil
}
out, err := helm.exec(append(args, flags...), map[string]string{}, nil)
out, err := helm.exec(append(args, flags...), map[string]string{})
if outputToFile {
// With --output-dir is passed to helm-template,
@@ -921,7 +918,7 @@ func (helm *execer) DiffRelease(context HelmContext, name, chart, namespace stri
flags = helm.filterColorFlagsForHelm4(flags, env)
}
out, err := helm.exec(append(append(preArgs, "diff", "upgrade", "--allow-unreleased", name, chart), flags...), env, overrideEnableLiveOutput)
out, err := helm.execWithContext(context.Ctx, append(append(preArgs, "diff", "upgrade", "--allow-unreleased", name, chart), flags...), env, overrideEnableLiveOutput)
// Do our best to write STDOUT only when diff existed
// Unfortunately, this works only when you run helmfile with `--detailed-exitcode`
detailedExitcodeEnabled := false
@@ -978,7 +975,7 @@ func (helm *execer) filterColorFlagsForHelm4(flags []string, env map[string]stri
func (helm *execer) Lint(name, chart string, flags ...string) error {
helm.logger.Infof("Linting release=%v, chart=%v", name, chart)
out, err := helm.exec(append([]string{"lint", chart}, flags...), map[string]string{}, nil)
out, err := helm.exec(append([]string{"lint", chart}, flags...), map[string]string{})
// Always write to stdout to write the linting result to eg. a file
helm.write(nil, out)
return err
@@ -1003,14 +1000,14 @@ func (helm *execer) Unittest(name, chart string, flags ...string) error {
}
helm.logger.Infof("Unit testing release=%v, chart=%v", name, chart)
out, err := helm.exec(append([]string{"unittest", chart}, flags...), map[string]string{}, nil)
out, err := helm.exec(append([]string{"unittest", chart}, flags...), map[string]string{})
helm.write(nil, out)
return err
}
func (helm *execer) Fetch(chart string, flags ...string) error {
helm.logger.Infof("Fetching %v", redactedURL(chart))
out, err := helm.exec(append([]string{"fetch", chart}, flags...), map[string]string{}, nil)
out, err := helm.exec(append([]string{"fetch", chart}, flags...), map[string]string{})
helm.info(out)
return err
}
@@ -1032,7 +1029,7 @@ func (helm *execer) ChartPull(chart string, path string, flags ...string) error
} else {
helmArgs = []string{"chart", "pull", chart}
}
out, err := helm.exec(helmArgs, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, nil)
out, err := helm.exec(helmArgs, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"})
helm.info(out)
return err
}
@@ -1048,7 +1045,7 @@ func (helm *execer) ChartExport(chart string, path string) error {
helm.logger.Infof("Exporting %v", chart)
helmArgs = []string{"chart", "export", chart, "--destination", path}
// no extra flags for before v3.7.0, details in helm chart export --help
out, err := helm.exec(helmArgs, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"}, nil)
out, err := helm.exec(helmArgs, map[string]string{"HELM_EXPERIMENTAL_OCI": "1"})
helm.info(out)
return err
}
@@ -1057,7 +1054,7 @@ func (helm *execer) DeleteRelease(context HelmContext, name string, flags ...str
helm.logger.Infof("Deleting %v", name)
preArgs := make([]string, 0)
env := make(map[string]string)
out, err := helm.exec(append(append(preArgs, "delete", name), flags...), env, nil)
out, err := helm.execWithContext(context.Ctx, append(append(preArgs, "delete", name), flags...), env, nil)
helm.info(out)
return err
}
@@ -1067,7 +1064,7 @@ func (helm *execer) TestRelease(context HelmContext, name string, flags ...strin
preArgs := make([]string, 0)
env := make(map[string]string)
args := []string{"test", name}
out, err := helm.exec(append(append(preArgs, args...), flags...), env, nil)
out, err := helm.execWithContext(context.Ctx, append(append(preArgs, args...), flags...), env, nil)
helm.info(out)
return err
}
@@ -1081,7 +1078,7 @@ func (helm *execer) AddPlugin(name, path, version string) error {
}
// Try with verification first
out, err := helm.exec([]string{"plugin", "install", path, "--version", version}, map[string]string{}, nil)
out, err := helm.exec([]string{"plugin", "install", path, "--version", version}, map[string]string{})
// If verification fails, retry without verification (unless enforced)
if err != nil && strings.Contains(err.Error(), "does not support verification") {
@@ -1090,7 +1087,7 @@ func (helm *execer) AddPlugin(name, path, version string) error {
return fmt.Errorf("plugin %s does not support verification (remove --enforce-plugin-verification flag to allow unverified plugins)", name)
}
helm.logger.Debugf("Plugin %v does not support verification, retrying with --verify=false", name)
out, err = helm.exec([]string{"plugin", "install", path, "--version", version, "--verify=false"}, map[string]string{}, nil)
out, err = helm.exec([]string{"plugin", "install", path, "--version", version, "--verify=false"}, map[string]string{})
}
helm.info(out)
@@ -1121,7 +1118,7 @@ func (helm *execer) installHelmSecretsV4(version string) error {
args = append(args, verifyFlag)
}
out, err := helm.exec(args, map[string]string{}, nil)
out, err := helm.exec(args, map[string]string{})
if err != nil {
return fmt.Errorf("failed to install %s: %w", plugin, err)
}
@@ -1159,7 +1156,7 @@ func helmSecretsRequiresSplitInstall(version string) bool {
func (helm *execer) uninstallPlugin(name string) error {
helm.logger.Infof("Uninstalling helm plugin %v", name)
out, err := helm.exec([]string{"plugin", "uninstall", name}, map[string]string{}, nil)
out, err := helm.exec([]string{"plugin", "uninstall", name}, map[string]string{})
if err == nil {
helm.info(out)
}
@@ -1204,7 +1201,28 @@ func (helm *execer) UpdatePlugin(name, repo, version string) error {
return helm.AddPlugin(name, repo, version)
}
func (helm *execer) exec(args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) {
func (helm *execer) exec(args []string, env map[string]string) ([]byte, error) {
return helm.execWithRunner(helm.runner, args, env, nil)
}
// execWithContext behaves like exec but attaches the span carried by ctx
// (from HelmContext.Ctx) so the subprocess span nests under the per-release
// span. Crucially, the subprocess keeps the runner's own context: replacing
// it would override specialized cancellation contexts such as the kubedog
// safety valve installed via execer.WithContext. A nil ctx is exactly exec.
func (helm *execer) execWithContext(ctx context.Context, args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) {
runner := helm.runner
if ctx != nil {
runner = withRunnerCtx(runner, func(runnerCtx context.Context) context.Context {
return spanAttachedContext(runnerCtx, ctx)
})
}
return helm.execWithRunner(runner, args, env, overrideEnableLiveOutput)
}
func (helm *execer) execWithRunner(runner Runner, args []string, env map[string]string, overrideEnableLiveOutput *bool) ([]byte, error) {
runner = markHelmRunner(runner)
cmdargs := args
if len(helm.extra) > 0 {
cmdargs = append(cmdargs, helm.extra...)
@@ -1221,7 +1239,7 @@ func (helm *execer) exec(args []string, env map[string]string, overrideEnableLiv
if overrideEnableLiveOutput != nil {
enableLiveOutput = *overrideEnableLiveOutput
}
outBytes, err := helm.runner.Execute(helm.helmBinary, cmdargs, env, enableLiveOutput)
outBytes, err := runner.Execute(helm.helmBinary, cmdargs, env, enableLiveOutput)
return outBytes, err
}
@@ -1238,7 +1256,7 @@ func (helm *execer) execStdIn(args []string, env map[string]string, stdin io.Rea
}
cmd := fmt.Sprintf("exec: %s %s", helm.helmBinary, strings.Join(cmdargs, " "))
helm.logger.Debug(cmd)
outBytes, err := helm.runner.ExecuteStdIn(helm.helmBinary, cmdargs, env, stdin)
outBytes, err := markHelmRunner(helm.runner).ExecuteStdIn(helm.helmBinary, cmdargs, env, stdin)
return outBytes, err
}
@@ -1348,15 +1366,24 @@ func resolveOciChart(ociChart string) (ociChartURL, ociChartTag string) {
}
func (helm *execer) ShowChart(chartPath string) (chart.Metadata, error) {
var helmArgs = []string{"show", "chart", chartPath}
out, error := helm.exec(helmArgs, map[string]string{}, nil)
if error != nil {
return chart.Metadata{}, error
return helm.ShowChartWithFlags(chartPath)
}
// ShowChartWithFlags runs `helm show chart` and unmarshals the resulting
// Chart.yaml. Callers may pass additional helm flags (for example --version,
// --plain-http, --registry-config, --ca-file, --insecure-skip-tls-verify).
// When --version references a semver constraint, helm resolves it against the
// registry and returns the concrete matching Chart.yaml, so callers can read
// metadata.Version to obtain the resolved version.
func (helm *execer) ShowChartWithFlags(chartPath string, flags ...string) (chart.Metadata, error) {
helmArgs := append([]string{"show", "chart", chartPath}, flags...)
out, err := helm.exec(helmArgs, map[string]string{})
if err != nil {
return chart.Metadata{}, err
}
var metadata chart.Metadata
error = yaml.Unmarshal(out, &metadata)
if error != nil {
return chart.Metadata{}, error
if err := yaml.Unmarshal(out, &metadata); err != nil {
return chart.Metadata{}, err
}
return metadata, nil
}
+2 -4
View File
@@ -56,8 +56,7 @@ func TestFilterDependencyFlags_AllGlobalFlags(t *testing.T) {
}
var expectedFlags []string
for i := 0; i < envType.NumField(); i++ {
field := envType.Field(i)
for field := range envType.Fields() {
if field.IsExported() {
flagName := "--" + toKebabCase(field.Name)
expectedFlags = append(expectedFlags, flagName)
@@ -106,8 +105,7 @@ func TestFilterDependencyFlags_AllDependencyFlags(t *testing.T) {
}
var expectedFlags []string
for i := 0; i < depType.NumField(); i++ {
field := depType.Field(i)
for field := range depType.Fields() {
if field.IsExported() {
flagName := "--" + toKebabCase(field.Name)
expectedFlags = append(expectedFlags, flagName)
+6 -6
View File
@@ -1182,7 +1182,7 @@ func Test_exec(t *testing.T) {
t.Errorf("unexpected error: %v", err)
}
env := map[string]string{}
_, err = helm.exec([]string{"version"}, env, nil)
_, err = helm.exec([]string{"version"}, env)
expected := `exec: helm version
`
if err != nil {
@@ -1196,7 +1196,7 @@ func Test_exec(t *testing.T) {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
ret, _ := helm.exec([]string{"diff"}, env, nil)
ret, _ := helm.exec([]string{"diff"}, env)
if len(ret) != 0 {
t.Error("helmexec.exec() - expected empty return value")
}
@@ -1206,7 +1206,7 @@ func Test_exec(t *testing.T) {
if err != nil {
t.Errorf("unexpected error: %v", err)
}
_, err = helm.exec([]string{"diff", "release", "chart", "--timeout 10", "--wait", "--wait-for-jobs"}, env, nil)
_, 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 {
@@ -1217,7 +1217,7 @@ func Test_exec(t *testing.T) {
}
buffer.Reset()
_, err = helm.exec([]string{"version"}, env, nil)
_, err = helm.exec([]string{"version"}, env)
expected = `exec: helm --kubeconfig config --kube-context dev version
`
if err != nil {
@@ -1229,7 +1229,7 @@ func Test_exec(t *testing.T) {
buffer.Reset()
helm.SetExtraArgs("foo")
_, err = helm.exec([]string{"version"}, env, nil)
_, err = helm.exec([]string{"version"}, env)
expected = `exec: helm --kubeconfig config --kube-context dev version foo
`
if err != nil {
@@ -1245,7 +1245,7 @@ func Test_exec(t *testing.T) {
t.Errorf("unexpected error: %v", err)
}
helm.SetHelmBinary("overwritten")
_, err = helm.exec([]string{"version"}, env, nil)
_, err = helm.exec([]string{"version"}, env)
expected = `exec: overwritten version
`
if err != nil {
+16 -13
View File
@@ -6,32 +6,35 @@ import (
)
func newExitError(path string, args []string, exitStatus int, err error, stderr, combined string, stripArgsValuesOnExitError bool) ExitError {
var out string
var out strings.Builder
out += fmt.Sprintf("PATH:\n%s", Indent(path, " "))
fmt.Fprintf(&out, "PATH:\n%s", Indent(path, " "))
out += "\n\nARGS:"
for i, a := range args {
if i > 0 && strings.HasPrefix(args[i-1], "--set") && stripArgsValuesOnExitError {
a = "*** STRIP ***"
}
out += fmt.Sprintf("\n%s", Indent(fmt.Sprintf("%d: %s (%d bytes)", i, a, len(a)), " "))
out.WriteString("\n\nARGS:")
// The legacy profile is byte-identical to the historical inline logic;
// the goldens in exit_error_test.go pin its exact output.
redacted := RedactArgs(args, RedactionLegacy)
if !stripArgsValuesOnExitError {
redacted = args
}
for i, a := range redacted {
fmt.Fprintf(&out, "\n%s", Indent(fmt.Sprintf("%d: %s (%d bytes)", i, a, len(a)), " "))
}
out += fmt.Sprintf("\n\nERROR:\n%s", Indent(err.Error(), " "))
fmt.Fprintf(&out, "\n\nERROR:\n%s", Indent(err.Error(), " "))
out += fmt.Sprintf("\n\nEXIT STATUS\n%s", Indent(fmt.Sprintf("%d", exitStatus), " "))
fmt.Fprintf(&out, "\n\nEXIT STATUS\n%s", Indent(fmt.Sprintf("%d", exitStatus), " "))
if len(stderr) > 0 {
out += fmt.Sprintf("\n\nSTDERR:\n%s", Indent(stderr, " "))
fmt.Fprintf(&out, "\n\nSTDERR:\n%s", Indent(stderr, " "))
}
if len(combined) > 0 {
out += fmt.Sprintf("\n\nCOMBINED OUTPUT:\n%s", Indent(combined, " "))
fmt.Fprintf(&out, "\n\nCOMBINED OUTPUT:\n%s", Indent(combined, " "))
}
return ExitError{
Message: fmt.Sprintf("command %q exited with non-zero status:\n\n%s", path, out),
Message: fmt.Sprintf("command %q exited with non-zero status:\n\n%s", path, out.String()),
Code: exitStatus,
}
}
+15
View File
@@ -46,3 +46,18 @@ type DependencyUpdater interface {
IsHelm3() bool
IsHelm4() bool
}
// ChartInspector is a capability interface exposing `helm show chart` with
// arbitrary flags. It is implemented by helmexec's concrete execer and by
// stubs in tests. Callers should type-assert Interface to ChartInspector and
// fall back gracefully when the implementation does not satisfy it, so that
// third-party implementations of helmexec.Interface keep compiling. See
// state.HelmState.resolveOCIConstraintVersion for the usage pattern.
type ChartInspector interface {
// ShowChartWithFlags runs `helm show chart <chart> [flags...]` and returns
// the parsed Chart.yaml metadata. Unlike ShowChart, callers pass flags
// such as --version, --plain-http, or --registry-config so that
// constraint versions (e.g. "~1", "^2.0.0") can be resolved against an
// OCI registry to a concrete semver.
ShowChartWithFlags(chart string, flags ...string) (chart.Metadata, error)
}
+1 -1
View File
@@ -23,7 +23,7 @@ type logWriter struct {
}
func (w *logWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(string(p), "\n") {
for line := range strings.SplitSeq(string(p), "\n") {
w.log.Debugf("%s%s", w.prefix, strings.TrimSpace(line))
}
return len(p), nil
+104
View File
@@ -0,0 +1,104 @@
package helmexec
import (
"slices"
"strings"
)
// RedactionProfile selects how aggressively RedactArgs masks secret-bearing
// command-line arguments.
type RedactionProfile int
const (
// RedactionLegacy reproduces the historical exit-error behavior
// byte-for-byte: only the argument *following* a flag whose name starts
// with "--set" is masked. The goldens in exit_error_test.go pin this
// output; changing it changes observable error messages.
RedactionLegacy RedactionProfile = iota
// RedactionStrict masks every secret-bearing argument form known today:
// in addition to the legacy behavior it covers single-argument forms
// (--set=key=value) and credential flags (--username, --password,
// --key-file). Used for telemetry span attributes; span visibility must
// be at least as redacted as error messages.
RedactionStrict
)
// redactedArg is the placeholder written in place of secret values; its exact
// bytes are pinned by exit_error_test.go.
const redactedArg = "*** STRIP ***"
// strictNextArgFlags are the flags whose following argument is a secret.
var strictNextArgFlags = []string{
"--set",
"--set-string",
"--set-file",
"--set-json",
"--set-literal",
"--username",
"--password",
"--key-file",
"--kube-token",
}
// RedactArgs returns a copy of args with secret-bearing values masked
// according to profile. The input slice is never mutated.
func RedactArgs(args []string, profile RedactionProfile) []string {
if len(args) == 0 {
return args
}
out := make([]string, len(args))
copy(out, args)
for i := range out {
// The previous token must be read from the ORIGINAL slice: reading
// the progressively redacted output would let a masked value hide a
// following secret (e.g. {"--set", "--set-string", "secret"}).
var prev string
if i > 0 {
prev = args[i-1]
}
switch profile {
case RedactionLegacy:
if strings.HasPrefix(prev, "--set") {
out[i] = redactedArg
}
case RedactionStrict:
if isStrictNextArgFlag(prev) {
out[i] = redactedArg
} else if flag := strictInlineFlag(out[i]); flag != "" {
out[i] = flag + "=" + redactedArg
}
}
}
return out
}
func isStrictNextArgFlag(arg string) bool {
return slices.Contains(strictNextArgFlags, arg)
}
// strictInlineFlag returns the flag name when arg is a single-argument secret
// form such as "--set=key=value", or "" otherwise.
func strictInlineFlag(arg string) string {
for _, flag := range strictNextArgFlags {
if strings.HasPrefix(arg, flag+"=") {
return flag
}
}
return ""
}
func equalArgs(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+241
View File
@@ -0,0 +1,241 @@
package helmexec
import (
"context"
"fmt"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
"go.opentelemetry.io/otel/attribute"
)
func TestRedactArgsLegacy(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{
name: "two-argument --set is masked",
args: []string{"upgrade", "--set", "secret=1"},
want: []string{"upgrade", "--set", redactedArg},
},
{
name: "--set prefix covers --set-string/--set-file/--set-json",
args: []string{"--set-string", "a=b", "--set-file", "f", "--set-json", "{}"},
want: []string{"--set-string", redactedArg, "--set-file", redactedArg, "--set-json", redactedArg},
},
{
name: "single-argument --set=k=v is NOT masked (documented legacy gap)",
args: []string{"--set=secret=1"},
want: []string{"--set=secret=1"},
},
{
name: "credential flags are NOT masked (documented legacy gap)",
args: []string{"--username", "bob", "--password", "hunter2"},
want: []string{"--username", "bob", "--password", "hunter2"},
},
{
name: "first argument is never masked",
args: []string{"--set"},
want: []string{"--set"},
},
{
name: "adjacent secret flags do not leak (previous token read from original)",
args: []string{"--set", "--set-string", "secret"},
want: []string{"--set", redactedArg, redactedArg},
},
{
name: "strict: adjacent secret flags do not leak",
args: []string{"--set", "--set", "a=b"},
want: []string{"--set", redactedArg, redactedArg},
},
{
name: "empty args",
args: []string{},
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RedactArgs(tt.args, RedactionLegacy)
assert.Equal(t, tt.want, got)
})
}
}
func TestRedactArgsStrict(t *testing.T) {
tests := []struct {
name string
args []string
want []string
}{
{
name: "legacy two-argument forms remain masked",
args: []string{"upgrade", "--set", "secret=1", "--set-string", "s=2"},
want: []string{"upgrade", "--set", redactedArg, "--set-string", redactedArg},
},
{
name: "single-argument --set=k=v is masked",
args: []string{"--set=secret=1", "--set-string=a=b", "--set-json={}"},
want: []string{"--set=" + redactedArg, "--set-string=" + redactedArg, "--set-json=" + redactedArg},
},
{
name: "credential flags are masked",
args: []string{"registry", "login", "--username", "bob", "--password", "hunter2"},
want: []string{"registry", "login", "--username", redactedArg, "--password", redactedArg},
},
{
name: "key-file is masked",
args: []string{"--key-file", "/etc/secrets/key"},
want: []string{"--key-file", redactedArg},
},
{
name: "password-stdin has no value to mask",
args: []string{"--password-stdin"},
want: []string{"--password-stdin"},
},
{
name: "kube-token is masked (two-argument form)",
args: []string{"--kube-context", "ctx", "upgrade", "--kube-token", "eyJhbGciOi..."},
want: []string{"--kube-context", "ctx", "upgrade", "--kube-token", redactedArg},
},
{
name: "kube-token is masked (inline form)",
args: []string{"upgrade", "--kube-token=eyJhbGciOi..."},
want: []string{"upgrade", "--kube-token=" + redactedArg},
},
{
name: "benign flags untouched",
args: []string{"upgrade", "--install", "envoy", "./chart", "--namespace", "ingress", "--reset-values"},
want: []string{"upgrade", "--install", "envoy", "./chart", "--namespace", "ingress", "--reset-values"},
},
{
name: "prefix lookalikes untouched",
args: []string{"--settlement", "x", "--usernames", "y"},
want: []string{"--settlement", "x", "--usernames", "y"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RedactArgs(tt.args, RedactionStrict)
assert.Equal(t, tt.want, got)
})
}
}
func TestRedactArgsDoesNotMutateInput(t *testing.T) {
orig := []string{"--set", "secret=1", "--set=x=y"}
before := reflect.ValueOf(orig).Pointer()
_ = RedactArgs(orig, RedactionStrict)
assert.Equal(t, []string{"--set", "secret=1", "--set=x=y"}, orig, "input slice must not be mutated")
assert.Equal(t, before, reflect.ValueOf(orig).Pointer(), "input backing array must be reused by the caller, not modified")
}
func TestClassifyExec(t *testing.T) {
tests := []struct {
name string
cmd string
args []string
wantName string
wantAttrVals map[string][]string
ctxMarked bool
}{
{
name: "helm invocation",
cmd: "/usr/local/bin/helm",
args: []string{"upgrade", "--install", "envoy", "./chart"},
wantName: "helm.exec",
wantAttrVals: map[string][]string{
"exec.command": {"helm"},
"helm.subcommand": {"upgrade"},
},
},
{
name: "helm with leading flags: subcommand is first positional",
cmd: "helm",
args: []string{"--kube-context", "ctx", "repo", "update"},
wantName: "helm.exec",
wantAttrVals: map[string][]string{
"helm.subcommand": {"repo"},
},
},
{
name: "non-helm binary",
cmd: "./scripts/migrate.sh",
args: []string{"--verbose"},
wantName: "os.exec",
wantAttrVals: map[string][]string{
"exec.command": {"migrate.sh"},
},
},
{
name: "wrapper helm binary classified via marker, not name",
cmd: "/opt/bin/custom-wrapper",
args: []string{"upgrade", "demo", "./chart"},
wantName: "helm.exec",
wantAttrVals: map[string][]string{
"exec.command": {"custom-wrapper"},
"helm.subcommand": {"upgrade"},
},
ctxMarked: true,
},
{
name: "secret args are redacted and flagged",
cmd: "helm",
args: []string{"upgrade", "--set", "pw=1", "--set=pw2=2"},
wantName: "helm.exec",
wantAttrVals: map[string][]string{
"exec.args": {"upgrade", "--set", redactedArg, "--set=" + redactedArg},
"exec.redacted": {"true"},
},
},
{
name: "credentials embedded in positional URLs are masked",
cmd: "helm",
args: []string{"repo", "add", "name", "https://user:token@charts.example.com/repo"},
wantName: "helm.exec",
wantAttrVals: map[string][]string{
"exec.args": {"repo", "add", "name", "https://xxxxx@charts.example.com/repo"},
"exec.redacted": {"true"},
},
},
{
name: "plain URLs are untouched",
cmd: "helm",
args: []string{"upgrade", "demo", "https://charts.example.com/repo/chart"},
wantName: "helm.exec",
wantAttrVals: map[string][]string{
"exec.args": {"upgrade", "demo", "https://charts.example.com/repo/chart"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
if tt.ctxMarked {
ctx = context.WithValue(ctx, helmExecMarker{}, true)
}
name, attrs := classifyExec(ctx, tt.cmd, tt.args)
assert.Equal(t, tt.wantName, name)
got := map[string][]string{}
for _, kv := range attrs {
switch kv.Value.Type() {
case attribute.STRING:
got[string(kv.Key)] = []string{kv.Value.AsString()}
case attribute.STRINGSLICE:
got[string(kv.Key)] = kv.Value.AsStringSlice()
case attribute.BOOL:
got[string(kv.Key)] = []string{fmt.Sprintf("%t", kv.Value.AsBool())}
}
}
for key, want := range tt.wantAttrVals {
assert.Equal(t, want, got[key], "attribute %s", key)
}
})
}
}
+91
View File
@@ -0,0 +1,91 @@
package helmexec
import (
"net/url"
"strings"
)
// redactedRefValue is the placeholder substituted for credential-bearing
// userinfo and query-parameter values in telemetry attributes.
const redactedRefValue = "xxxxx"
// sensitiveQueryKeys reports whether a query-parameter key is considered
// credential-bearing, mirroring the heuristic used by pkg/remote for cache
// keys (token/password/secret/key/signature substrings).
func sensitiveQueryKey(key string) bool {
lk := strings.ToLower(key)
return strings.Contains(lk, "token") || strings.Contains(lk, "password") ||
strings.Contains(lk, "secret") || strings.Contains(lk, "key") ||
strings.Contains(lk, "signature")
}
// RedactedRef sanitizes a remote reference (state-file path, chart reference,
// repository URL) for telemetry export: go-getter forced-form prefixes
// (git::, s3::, …) are preserved, URL userinfo is masked, and
// credential-bearing query parameters have their values replaced. Non-URL
// strings are returned unchanged. It is deliberately at least as strict as
// the log-time RedactedURL.
// maxForcedFormPrefix bounds the length of a recognized go-getter forced-form
// prefix ("git::", "s3::", "hg::" …); longer "::"-containing prefixes are
// treated as part of an opaque reference instead.
const maxForcedFormPrefix = 16
func RedactedRef(ref string) string {
force := ""
rest := ref
// A go-getter forced form looks like "git::https://…": a short
// alphanumeric prefix followed by "::" at the start of the reference.
if i := strings.Index(rest, "::"); i > 0 && i <= maxForcedFormPrefix && isAlphanumericPrefix(rest[:i]) {
force = rest[:i+len("::")]
rest = rest[i+len("::"):]
}
u, err := url.Parse(rest)
if err != nil {
if strings.Contains(rest, "://") {
// URL-like but malformed (e.g. a bad percent escape): fail
// closed — export a fully redacted value rather than risk
// leaking userinfo or query credentials.
return force + redactedRefValue
}
return ref
}
if u.Scheme == "" || u.Host == "" {
// Not a URL (e.g. "./charts/demo"); leave it alone.
return ref
}
// Mask the whole userinfo: usernames are as likely to carry tokens as
// passwords are (e.g. "https://x-access-token@host").
if u.User != nil {
u.User = url.User(redactedRefValue)
}
if u.RawQuery != "" {
q, err := url.ParseQuery(u.RawQuery)
if err != nil {
// Malformed query: fail closed by dropping it entirely.
u.RawQuery = ""
} else {
for key, values := range q {
if sensitiveQueryKey(key) {
for i := range values {
values[i] = redactedRefValue
}
}
}
u.RawQuery = q.Encode()
}
}
return force + u.String()
}
func isAlphanumericPrefix(s string) bool {
for _, r := range s {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
return false
}
}
return len(s) > 0
}
+66
View File
@@ -0,0 +1,66 @@
package helmexec
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRedactedRef(t *testing.T) {
tests := []struct {
name string
ref string
want string
}{
{
name: "plain chart path unchanged",
ref: "./charts/demo",
want: "./charts/demo",
},
{
name: "https userinfo fully masked (username may carry the token)",
ref: "https://user:token@charts.example.com/repo",
want: "https://xxxxx@charts.example.com/repo",
},
{
name: "go-getter forced form preserved and sanitized",
ref: "git::https://x-access-token@github.com/org/repo.git//helmfile?ref=v1",
want: "git::https://xxxxx@github.com/org/repo.git//helmfile?ref=v1",
},
{
name: "s3 query credentials masked",
ref: "s3::https://s3.amazonaws.com/bucket/helmfile?aws_access_key_id=AKIA&aws_secret_access_key=zzz&region=us-east-1",
want: "s3::https://s3.amazonaws.com/bucket/helmfile?aws_access_key_id=xxxxx&aws_secret_access_key=xxxxx&region=us-east-1",
},
{
name: "generic token query param masked",
ref: "https://example.com/file?token=abc&path=ok",
want: "https://example.com/file?path=ok&token=xxxxx",
},
{
name: "non-credential query untouched",
ref: "https://example.com/chart?ref=main&verify=true",
want: "https://example.com/chart?ref=main&verify=true",
},
{
name: "URL without scheme or host returned unchanged",
ref: "git@github.com:org/repo.git",
want: "git@github.com:org/repo.git",
},
{
name: "malformed URL-like ref fails fully redacted",
ref: "https://user:token@host/%zz",
want: redactedRefValue,
},
{
name: "malformed query is dropped entirely",
ref: "https://host/file?token=secret%zz&ok=1",
want: "https://host/file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, RedactedRef(tt.ref))
})
}
}
+31 -8
View File
@@ -7,11 +7,13 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"go.uber.org/zap"
@@ -34,28 +36,51 @@ type ShellRunner struct {
Ctx context.Context
}
// Execute a shell command
// Execute a shell command. The whole execution is wrapped in one span
// (helm.exec/os.exec, see span.go); span overhead is nil when telemetry is
// disabled.
func (shell ShellRunner) Execute(cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
ctx, span, isHelm := startExecSpan(shell.Ctx, cmd, args)
defer span.End()
start := time.Now()
out, err := shell.run(ctx, cmd, args, env, enableLiveOutput)
finishExecSpan(span, isHelm, args, start, err)
return out, err
}
func (shell ShellRunner) run(ctx context.Context, cmd string, args []string, env map[string]string, enableLiveOutput bool) ([]byte, error) {
preparedCmd := exec.Command(cmd, args...)
preparedCmd.Dir = shell.Dir
preparedCmd.Env = mergeEnv(os.Environ(), env)
if !enableLiveOutput {
return Output(shell.Ctx, preparedCmd, shell.StripArgsValuesOnExitError, &logWriterGenerator{
return Output(ctx, preparedCmd, shell.StripArgsValuesOnExitError, &logWriterGenerator{
log: shell.Logger,
})
} else {
return LiveOutput(shell.Ctx, preparedCmd, shell.StripArgsValuesOnExitError, os.Stdout)
return LiveOutput(ctx, preparedCmd, shell.StripArgsValuesOnExitError, os.Stdout)
}
}
// Execute a shell command
// Execute a shell command with the given stdin; wrapped in an exec span like
// Execute.
func (shell ShellRunner) ExecuteStdIn(cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
ctx, span, isHelm := startExecSpan(shell.Ctx, cmd, args)
defer span.End()
start := time.Now()
out, err := shell.runStdIn(ctx, cmd, args, env, stdin)
finishExecSpan(span, isHelm, args, start, err)
return out, err
}
func (shell ShellRunner) runStdIn(ctx context.Context, cmd string, args []string, env map[string]string, stdin io.Reader) ([]byte, error) {
preparedCmd := exec.Command(cmd, args...)
preparedCmd.Dir = shell.Dir
preparedCmd.Env = mergeEnv(os.Environ(), env)
preparedCmd.Stdin = stdin
return Output(shell.Ctx, preparedCmd, shell.StripArgsValuesOnExitError, &logWriterGenerator{
return Output(ctx, preparedCmd, shell.StripArgsValuesOnExitError, &logWriterGenerator{
log: shell.Logger,
})
}
@@ -187,9 +212,7 @@ func LiveOutput(ctx context.Context, c *exec.Cmd, stripArgsValuesOnExitError boo
func mergeEnv(orig []string, new map[string]string) []string {
wanted := env2map(orig)
for k, v := range new {
wanted[k] = v
}
maps.Copy(wanted, new)
return map2env(wanted)
}
+167
View File
@@ -0,0 +1,167 @@
package helmexec
import (
"context"
"errors"
"path/filepath"
"strings"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/helmfile/helmfile/pkg/telemetry"
)
// startExecSpan starts one span per external process started by helmfile
// (helm invocations, hooks, helmfile plugins). It is effectively free when
// telemetry is disabled: telemetry.Tracer then returns the OTel no-op tracer.
// The returned context derives from ctx (or Background when nil) and may be
// used for the subprocess itself without changing cancellation semantics.
// markHelmExec returns ctx (or Background when nil) stamped with the
// helm-invocation marker used for span classification.
func markHelmExec(ctx context.Context) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, helmExecMarker{}, true)
}
// withRunnerCtx returns a runner (value or pointer ShellRunner form) whose
// context is transformed by f; non-ShellRunner runners pass through
// unchanged. ShellRunner has value receivers, so both forms satisfy Runner.
func withRunnerCtx(runner Runner, f func(context.Context) context.Context) Runner {
switch shell := runner.(type) {
case *ShellRunner:
clone := *shell
clone.Ctx = f(clone.Ctx)
return &clone
case ShellRunner:
clone := shell
clone.Ctx = f(clone.Ctx)
return clone
default:
return runner
}
}
// markHelmRunner returns a runner whose context carries the helm-invocation
// marker, so span classification and the helm duration metric do not rely on
// the executable basename.
func markHelmRunner(runner Runner) Runner {
return withRunnerCtx(runner, markHelmExec)
}
// spanAttachedContext returns a context that keeps runnerCtx's cancellation
// chain but carries the span from spanCtx, so the subprocess span nests under
// the caller's span while the subprocess itself stays governed by the
// runner's own context (e.g. the kubedog safety valve). A nil runnerCtx falls
// back to spanCtx.
func spanAttachedContext(runnerCtx, spanCtx context.Context) context.Context {
if runnerCtx == nil {
return spanCtx
}
return trace.ContextWithSpan(runnerCtx, trace.SpanFromContext(spanCtx))
}
// helmExecMarker marks contexts of invocations made through the execer
// funnel, so span classification is authoritative even for wrapper binaries
// whose name does not start with "helm".
type helmExecMarker struct{}
func startExecSpan(ctx context.Context, cmd string, args []string) (context.Context, trace.Span, bool) {
if ctx == nil {
ctx = context.Background()
}
name, attrs := classifyExec(ctx, cmd, args)
ctx, span := telemetry.Tracer(telemetry.ScopeHelm).Start(ctx, name, trace.WithAttributes(attrs...))
return ctx, span, name == helmExecSpanName
}
// classifyExec builds the span name and attributes for one subprocess.
// Secret-bearing arguments are always redacted with the strict profile: span
// visibility must be at least as redacted as error messages (see redact.go).
// helmExecSpanName is the single source of truth for what counts as a helm
// invocation: the span name drives both classification and the helm duration
// metric gate.
const helmExecSpanName = "helm.exec"
func classifyExec(ctx context.Context, cmd string, args []string) (string, []attribute.KeyValue) {
base := filepath.Base(cmd)
isHelm := isHelmBinary(base)
if marked, ok := ctx.Value(helmExecMarker{}).(bool); ok {
isHelm = marked
}
attrs := []attribute.KeyValue{
attribute.String("exec.command", base),
}
redacted := RedactArgs(args, RedactionStrict)
for i, a := range redacted {
// Positional arguments can be chart/repository URLs with embedded
// credentials (AddRepo, RegistryLogin, OCI and go-getter refs).
redacted[i] = RedactedRef(a)
}
attrs = append(attrs, attribute.StringSlice("exec.args", redacted))
if !equalArgs(args, redacted) {
attrs = append(attrs, attribute.Bool("exec.redacted", true))
}
if isHelm {
if sub := helmSubcommand(args); sub != "" {
attrs = append(attrs, attribute.String("helm.subcommand", sub))
}
return helmExecSpanName, attrs
}
return "os.exec", attrs
}
// isHelmBinary is the FALLBACK classifier: a base name starting with "helm"
// ("helm", "helm3", "helm-dev") for processes started outside the execer
// funnels (e.g. the version probe in helmexec.New). Invocations through the
// funnels are classified authoritatively by the helmExecMarker, so wrapper
// --helm-binary names classify correctly too.
func isHelmBinary(base string) bool {
return strings.HasPrefix(base, "helm")
}
// helmSubcommand returns the best-effort helm subcommand ("upgrade",
// "repo", ...) from raw args. Bare flags are assumed to consume the next
// argument as their value (true for the global --kube-context/--kubeconfig
// helmfile prepends); a rare bare boolean flag before the subcommand can
// misattribute the value — cosmetic only.
func helmSubcommand(args []string) string {
skipValue := false
for _, a := range args {
if skipValue {
skipValue = false
continue
}
if strings.HasPrefix(a, "-") {
skipValue = !strings.Contains(a, "=")
continue
}
return a
}
return ""
}
// finishExecSpan records a finished process's outcome on its span and, for
// helm invocations (as classified by startExecSpan, wrapper binaries
// included), the helmfile.helm.exec.duration metric.
func finishExecSpan(span trace.Span, isHelm bool, args []string, start time.Time, err error) {
if isHelm {
telemetry.RecordHelmExecDuration(time.Since(start).Seconds(), helmSubcommand(args), err == nil)
}
if err == nil {
return
}
if exitErr, ok := errors.AsType[ExitError](err); ok {
span.SetAttributes(attribute.Int("exec.exit_code", exitErr.ExitStatus()))
}
// The raw error may embed command arguments and subprocess output; keep
// the span description generic (the exit code is an attribute).
span.SetStatus(codes.Error, "command failed")
}
+165
View File
@@ -0,0 +1,165 @@
package helmexec
import (
"context"
"io"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
commonpb "go.opentelemetry.io/proto/otlp/common/v1"
v1 "go.opentelemetry.io/proto/otlp/trace/v1"
"github.com/helmfile/helmfile/pkg/telemetry"
"github.com/helmfile/helmfile/pkg/telemetry/otlptest"
)
// TestShellRunnerExecSpansExported drives ShellRunner.Execute through a real
// OTLP export and asserts names, attributes, error status, and parent linkage
// to the command span (i.e. the runner spans nest, never orphan).
func TestShellRunnerExecSpansExported(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses the unix true/false binaries")
}
rec := otlptest.NewRecorder(t)
otlptest.SetupTelemetry(t, rec, "helmfile test")
shell := ShellRunner{
Logger: NewLogger(io.Discard, "warn"),
Ctx: telemetry.CommandContext(),
}
out, err := shell.Execute("true", nil, nil, false)
require.NoError(t, err)
assert.Empty(t, out)
// A failing command must surface its exit code and error status.
_, err = shell.Execute("false", nil, nil, false)
var exitErr ExitError
require.ErrorAs(t, err, &exitErr)
// Secret-bearing arguments must be redacted on the span.
_, err = shell.Execute("true", []string{"--set", "secret=1", "--set=also=2"}, nil, false)
require.NoError(t, err)
otlptest.ShutdownTelemetry(t)
spans := rec.Spans(t)
root := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return s.Name == "helmfile test" }, "command span")
success := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool {
cmd, ok := otlptest.AttrString(s, "exec.command")
return s.Name == "os.exec" && ok && cmd == "true" && !otlptest.HasAttr(s, "exec.redacted")
}, "success os.exec span")
if success.Status != nil {
assert.Equal(t, v1.Status_STATUS_CODE_UNSET, success.Status.Code, "success span must not be marked error")
}
failure := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool {
cmd, ok := otlptest.AttrString(s, "exec.command")
return ok && cmd == "false"
}, "failure os.exec span")
require.NotNil(t, failure.Status)
assert.Equal(t, v1.Status_STATUS_CODE_ERROR, failure.Status.Code)
assert.EqualValues(t, 1, spanAttr(t, failure, "exec.exit_code").Value.GetIntValue())
redactedSpan := otlptest.FindSpanWhere(t, spans, func(s *v1.Span) bool { return otlptest.HasAttr(s, "exec.redacted") }, "redacted os.exec span")
assert.Equal(t, []string{"--set", redactedArg, "--set=" + redactedArg}, spanAttrStrings(t, redactedSpan, "exec.args"))
// Every os.exec span must be a child of the command span, proving the
// runner-span nesting (no orphan traces).
for _, s := range spans {
if s.Name == "os.exec" {
assert.Equal(t, root.TraceId, s.TraceId, "os.exec span must join the command trace")
assert.Equal(t, root.SpanId, s.ParentSpanId, "os.exec span must nest under the command span")
}
}
}
func spanAttr(t *testing.T, span *v1.Span, key string) *commonpb.KeyValue {
t.Helper()
for _, kv := range span.Attributes {
if kv.Key == key {
return kv
}
}
t.Fatalf("attribute %q not found on span %q", key, span.Name)
return nil
}
func spanAttrStrings(t *testing.T, span *v1.Span, key string) []string {
t.Helper()
values := spanAttr(t, span, key).Value.GetArrayValue().GetValues()
out := make([]string, 0, len(values))
for _, v := range values {
out = append(out, v.GetStringValue())
}
return out
}
// TestSpanAttachedContext pins the per-call context merge that lets helm
// subprocess spans nest under per-release spans WITHOUT overriding the
// runner's own cancellation context (the kubedog safety valve).
func TestSpanAttachedContext(t *testing.T) {
runnerCtx := t.Context()
spanCtx, span := noop.NewTracerProvider().Tracer("test").Start(context.Background(), "release")
merged := spanAttachedContext(runnerCtx, spanCtx)
// The merged context still cancels with the runner's context.
assert.Equal(t, runnerCtx.Done(), merged.Done(), "cancellation authority must stay with the runner context")
// ...and carries the caller's span for nesting.
assert.Equal(t, span, trace.SpanFromContext(merged), "the caller's span must be attached")
// A typed nil runner context (distinct from the nil literal, per
// staticcheck) falls back to the span context.
var nilRunnerCtx context.Context
assert.Equal(t, spanCtx, spanAttachedContext(nilRunnerCtx, spanCtx))
}
// TestMarkHelmRunnerCoversBothFunnels pins that the shared marker helper —
// used by BOTH execWithRunner and execStdIn — stamps value and pointer
// runners alike, so wrapper binaries stay classified as helm operations on
// stdin-based invocations (registry login, repo add) too.
func TestMarkHelmRunnerCoversBothFunnels(t *testing.T) {
ptr := &ShellRunner{}
if v, ok := markHelmRunner(ptr).(*ShellRunner).Ctx.Value(helmExecMarker{}).(bool); !ok || !v {
t.Error("pointer runner must carry the helm marker")
}
val := ShellRunner{}
markedValue, ok := markHelmRunner(val).(ShellRunner)
if !ok {
t.Fatal("value runner must stay a value runner")
}
if v, ok := markedValue.Ctx.Value(helmExecMarker{}).(bool); !ok || !v {
t.Error("value runner must carry the helm marker")
}
fake := &mockRunner{}
assert.Same(t, fake, markHelmRunner(fake), "non-ShellRunner runners pass through unchanged")
}
// TestValueRunnerClassification pins that ShellRunner values (which satisfy
// Runner via value receivers) receive the helm marker and span attachment
// exactly like pointer runners.
func TestValueRunnerClassification(t *testing.T) {
spanCtx, span := noop.NewTracerProvider().Tracer("test").Start(context.Background(), "release")
// marker stamping
valueRunner := ShellRunner{}
marked := markHelmExec(valueRunner.Ctx)
if v, ok := marked.Value(helmExecMarker{}).(bool); !ok || !v {
t.Fatal("value runner ctx must carry the helm marker")
}
// span attachment via the same path execWithContext uses for values
merged := spanAttachedContext(valueRunner.Ctx, spanCtx)
assert.Equal(t, span, trace.SpanFromContext(merged))
}
+157
View File
@@ -0,0 +1,157 @@
package kubedog
import (
"fmt"
"sync"
"time"
"github.com/werf/kubedog/pkg/informer"
kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/tools/cache"
)
type podLogCutoff struct {
uid types.UID
time time.Time
}
// podLogFilter remembers the first Ready transition independently of kubedog's
// workload readiness. It only filters output; pod tracking and log capture continue.
type podLogFilter struct {
mu sync.RWMutex
cutoffs map[string]podLogCutoff
}
func newPodLogFilter() *podLogFilter {
return &podLogFilter{cutoffs: make(map[string]podLogCutoff)}
}
// newStartupLogFilter returns a readiness-tracking log filter when log
// streaming is limited to startup output, or nil otherwise.
func newStartupLogFilter(options *TrackOptions, factory *kdutil.Concurrent[*informer.InformerFactory], targets []trackTarget) (*podLogFilter, error) {
if options == nil || !options.Logs || !options.LogsUntilReady {
return nil, nil
}
filter := newPodLogFilter()
if err := filter.watch(factory, targets); err != nil {
return nil, err
}
return filter, nil
}
// cutoffTrackedKind reports whether pods of the target kind are subject to
// readiness-based log cutoff. Jobs are excluded: they can be Ready while
// still executing.
func cutoffTrackedKind(kind string) bool {
switch kind {
case "deploy", "sts", "ds":
return true
default:
return false
}
}
// watchNamespacePods attaches the readiness handler to the shared pod
// informer for namespace and starts it.
func (f *podLogFilter) watchNamespacePods(factory *kdutil.Concurrent[*informer.InformerFactory], gvr schema.GroupVersionResource, namespace string) error {
var podInformer *kdutil.Concurrent[*informer.Informer]
err := factory.RWTransactionErr(func(factory *informer.InformerFactory) error {
informer, err := factory.ForNamespace(gvr, namespace)
if err != nil {
return fmt.Errorf("create pod log informer: %w", err)
}
podInformer = informer
return nil
})
if err != nil {
return err
}
return podInformer.RWTransactionErr(func(inf *informer.Informer) error {
_, err := inf.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: f.update,
UpdateFunc: func(_, obj any) {
f.update(obj)
},
})
if err != nil {
return fmt.Errorf("watch pod readiness for logs: %w", err)
}
inf.Run()
return nil
})
}
func (f *podLogFilter) watch(factory *kdutil.Concurrent[*informer.InformerFactory], targets []trackTarget) error {
podGVR := schema.GroupVersionResource{Version: "v1", Resource: "pods"}
namespaces := make(map[string]struct{})
for _, target := range targets {
if !cutoffTrackedKind(target.kind) {
continue
}
if _, watched := namespaces[target.namespace]; watched {
continue
}
if err := f.watchNamespacePods(factory, podGVR, target.namespace); err != nil {
return err
}
namespaces[target.namespace] = struct{}{}
}
return nil
}
func (f *podLogFilter) update(obj any) {
pod, ok := obj.(*unstructured.Unstructured)
if !ok {
return
}
id := kdutil.ResourceID(pod.GetName(), pod.GetNamespace(), watchdogPodGVK)
f.mu.Lock()
defer f.mu.Unlock()
// Keep the first Ready transition recorded for this pod instance. A
// replacement pod reusing the name starts fresh; delete on a missing key
// is a no-op.
if cutoff, found := f.cutoffs[id]; found && cutoff.uid == pod.GetUID() {
return
}
delete(f.cutoffs, id)
// Job pods can be Ready while still executing; keep their complete output.
for _, owner := range pod.GetOwnerReferences() {
if owner.Kind == "Job" {
return
}
}
conditions, _, err := unstructured.NestedSlice(pod.Object, "status", "conditions")
if err != nil {
return
}
for _, raw := range conditions {
condition, ok := raw.(map[string]any)
if !ok || condition["type"] != "Ready" || condition["status"] != "True" {
continue
}
timestamp, _ := condition["lastTransitionTime"].(string)
readyTime, err := time.Parse(time.RFC3339Nano, timestamp)
if err != nil {
readyTime = time.Now()
} else if readyTime.Nanosecond() == 0 {
// Kubernetes rounds Ready timestamps to seconds. Preserve startup
// lines within that second too, even when delivered after readiness.
readyTime = readyTime.Add(time.Second - time.Nanosecond)
}
f.cutoffs[id] = podLogCutoff{uid: pod.GetUID(), time: readyTime}
return
}
}
func (f *podLogFilter) cutoff(name, namespace string, gvk schema.GroupVersionKind) time.Time {
if f == nil {
return time.Time{}
}
f.mu.RLock()
defer f.mu.RUnlock()
return f.cutoffs[kdutil.ResourceID(name, namespace, gvk)].time
}
+206
View File
@@ -0,0 +1,206 @@
package kubedog
import (
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/werf/kubedog/pkg/informer"
kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic/fake"
clienttesting "k8s.io/client-go/testing"
"k8s.io/client-go/tools/cache"
)
func newLogFilterPod(name, namespace, uid, readyStatus string, readyTime time.Time) *unstructured.Unstructured {
pod := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{"name": name, "namespace": namespace, "uid": uid},
"status": map[string]any{"phase": "Running"},
}}
if readyStatus != "" {
condition := map[string]any{"type": "Ready", "status": readyStatus}
if !readyTime.IsZero() {
condition["lastTransitionTime"] = readyTime.Format(time.RFC3339Nano)
}
pod.Object["status"].(map[string]any)["conditions"] = []any{condition}
}
return pod
}
func TestPodLogFilter_ReadyCondition(t *testing.T) {
readyTime := time.Date(2026, 10, 1, 12, 0, 0, 123, time.UTC)
for _, status := range []string{"", "False", "Unknown", "True"} {
t.Run("Ready="+status, func(t *testing.T) {
filter := newPodLogFilter()
filter.update(newLogFilterPod("app", "ns", "uid", status, readyTime))
cutoff := filter.cutoff("app", "ns", watchdogPodGVK)
if status == "True" {
assert.Equal(t, readyTime, cutoff)
} else {
assert.True(t, cutoff.IsZero(), "Running alone does not mean that a pod is ready")
}
})
}
}
func TestPodLogFilter_FirstReadyTransitionIsRetained(t *testing.T) {
filter := newPodLogFilter()
firstReady := time.Date(2026, 10, 1, 12, 0, 0, 123, time.UTC)
filter.update(newLogFilterPod("app", "ns", "uid", "True", firstReady))
filter.update(newLogFilterPod("app", "ns", "uid", "False", firstReady.Add(time.Minute)))
filter.update(newLogFilterPod("app", "ns", "uid", "True", firstReady.Add(2*time.Minute)))
assert.Equal(t, firstReady, filter.cutoff("app", "ns", watchdogPodGVK))
}
func TestPodLogFilter_JobPodsAreExempt(t *testing.T) {
filter := newPodLogFilter()
pod := newLogFilterPod("job-pod", "ns", "uid", "True", time.Now())
pod.SetOwnerReferences([]metav1.OwnerReference{{APIVersion: "batch/v1", Kind: "Job", Name: "job"}})
filter.update(pod)
assert.True(t, filter.cutoff("job-pod", "ns", watchdogPodGVK).IsZero())
}
func TestPodLogFilter_ReplacementPodStartsLoggingAgain(t *testing.T) {
filter := newPodLogFilter()
firstReady := time.Date(2026, 10, 1, 12, 0, 0, 123, time.UTC)
filter.update(newLogFilterPod("app", "ns", "old-uid", "True", firstReady))
filter.update(newLogFilterPod("app", "ns", "new-uid", "False", time.Time{}))
assert.True(t, filter.cutoff("app", "ns", watchdogPodGVK).IsZero())
nextReady := firstReady.Add(time.Minute)
filter.update(newLogFilterPod("app", "ns", "new-uid", "True", nextReady))
assert.Equal(t, nextReady, filter.cutoff("app", "ns", watchdogPodGVK))
}
func TestPodLogFilter_NamespacesAreIndependent(t *testing.T) {
filter := newPodLogFilter()
readyTime := time.Date(2026, 10, 1, 12, 0, 0, 123, time.UTC)
filter.update(newLogFilterPod("app", "ready-ns", "uid", "True", readyTime))
filter.update(newLogFilterPod("app", "starting-ns", "uid", "False", time.Time{}))
assert.Equal(t, readyTime, filter.cutoff("app", "ready-ns", watchdogPodGVK))
assert.True(t, filter.cutoff("app", "starting-ns", watchdogPodGVK).IsZero())
}
func TestPodLogFilter_PreservesReadinessSecond(t *testing.T) {
filter := newPodLogFilter()
readyTime := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
filter.update(newLogFilterPod("app", "ns", "uid", "True", readyTime))
assert.Equal(t, readyTime.Add(time.Second-time.Nanosecond), filter.cutoff("app", "ns", watchdogPodGVK))
}
func TestPodLogFilter_MissingOrInvalidTransitionTime(t *testing.T) {
for _, timestamp := range []string{"", "invalid"} {
t.Run(timestamp, func(t *testing.T) {
filter := newPodLogFilter()
pod := newLogFilterPod("app", "ns", "uid", "True", time.Time{})
conditions := pod.Object["status"].(map[string]any)["conditions"].([]any)
conditions[0].(map[string]any)["lastTransitionTime"] = timestamp
before := time.Now()
filter.update(pod)
after := time.Now()
cutoff := filter.cutoff("app", "ns", watchdogPodGVK)
assert.False(t, cutoff.Before(before))
assert.False(t, cutoff.After(after))
})
}
}
func TestPodLogFilter_WatchesExistingPodsAndUpdatesUsingSharedInformer(t *testing.T) {
ctx := t.Context()
podGVR := schema.GroupVersionResource{Version: "v1", Resource: "pods"}
readyTime := time.Date(2026, 10, 1, 12, 0, 0, 123, time.UTC)
readyPod := newLogFilterPod("ready", "ns", "ready-uid", "True", readyTime)
startingPod := newLogFilterPod("starting", "ns", "starting-uid", "False", time.Time{})
client := fake.NewSimpleDynamicClientWithCustomListKinds(
runtime.NewScheme(), map[schema.GroupVersionResource]string{podGVR: "PodList"}, readyPod, startingPod,
)
var watches atomic.Int32
client.PrependWatchReactor("pods", func(clienttesting.Action) (bool, watch.Interface, error) {
watches.Add(1)
return false, nil, nil
})
factory := informer.NewConcurrentInformerFactory(ctx.Done(), make(chan error, 1), client,
informer.ConcurrentInformerFactoryOptions{})
// Start another consumer first, as kubedog does during resource tracking.
var shared *kdutil.Concurrent[*informer.Informer]
var err error
factory.RWTransaction(func(factory *informer.InformerFactory) {
shared, err = factory.ForNamespace(podGVR, "ns")
})
require.NoError(t, err)
shared.RWTransaction(func(inf *informer.Informer) {
_, err = inf.AddEventHandler(cache.ResourceEventHandlerFuncs{})
if err == nil {
inf.Run()
}
})
require.NoError(t, err)
filter := newPodLogFilter()
require.NoError(t, filter.watch(factory, []trackTarget{
{kind: "deploy", namespace: "ns"},
{kind: "sts", namespace: "ns"},
{kind: "job", namespace: "jobs"},
{kind: "pvc", namespace: "storage"},
{kind: "canary", namespace: "canaries"},
}))
require.Eventually(t, func() bool {
return filter.cutoff("ready", "ns", watchdogPodGVK).Equal(readyTime)
}, 5*time.Second, 10*time.Millisecond, "initial add must retain the pod's historical readiness time")
assert.True(t, filter.cutoff("starting", "ns", watchdogPodGVK).IsZero())
nextReady := readyTime.Add(time.Minute)
startingPod = newLogFilterPod("starting", "ns", "starting-uid", "True", nextReady)
_, err = client.Resource(podGVR).Namespace("ns").Update(ctx, startingPod, metav1.UpdateOptions{})
require.NoError(t, err)
require.Eventually(t, func() bool {
return filter.cutoff("starting", "ns", watchdogPodGVK).Equal(nextReady)
}, 5*time.Second, 10*time.Millisecond, "updates must stop logs independently of the other pod")
assert.EqualValues(t, 1, watches.Load(), "consumers and repeated targets must share one pod watch")
}
func TestCutoffTrackedKind(t *testing.T) {
for kind, want := range map[string]bool{
"deploy": true,
"sts": true,
"ds": true,
"job": false,
"pvc": false,
"canary": false,
} {
t.Run(kind, func(t *testing.T) {
assert.Equal(t, want, cutoffTrackedKind(kind))
})
}
}
func TestNewStartupLogFilter(t *testing.T) {
// Every combination below must yield a nil filter without touching the
// informer factory: startup-only cutoffs apply solely to full streaming.
nilFilterCases := []struct {
name string
options *TrackOptions
}{
{name: "nil options", options: nil},
{name: "no log streaming", options: NewTrackOptions().WithLogsUntilReady(true)},
{name: "streaming without cutoff", options: NewTrackOptions().WithLogs(true)},
{name: "failed-only mode", options: NewTrackOptions().WithFailedLogsOnly(true).WithLogsUntilReady(true)},
}
for _, tt := range nilFilterCases {
t.Run(tt.name, func(t *testing.T) {
filter, err := newStartupLogFilter(tt.options, nil, nil)
require.NoError(t, err)
assert.Nil(t, filter)
})
}
}
+30 -8
View File
@@ -26,18 +26,27 @@ const (
TrackModeKubedog TrackMode = "kubedog"
)
// defaultLogsInterval is how often the printer flushes captured logs when
// TrackOptions.LogsInterval is not set (zero).
const defaultLogsInterval = 10 * time.Second
type TrackOptions struct {
Timeout time.Duration
// Logs enables emitting logs for every pod kubedog observes.
Logs bool
// LogsUntilReady limits Logs to startup output through each pod's first
// Ready transition. Job logs are unaffected. Has no effect unless Logs is true.
LogsUntilReady bool
// FailedLogsOnly enables capturing logs in the background and emitting
// them only for pods that enter a failed state (CrashLoopBackOff, Error,
// ImagePullBackOff, etc.). Has no effect when Logs is true.
FailedLogsOnly bool
LogsSince time.Duration
Filter *resource.FilterConfig
QPS float32
Burst int
// LogsInterval controls how often captured logs are printed.
LogsInterval time.Duration
LogsSince time.Duration
Filter *resource.FilterConfig
QPS float32
Burst int
// Baselines holds the pre-change state of each resource keyed by
// "Kind/Namespace/Name". When set, the tracker delays attaching kubedog
// to a resource until its UID changes or its generation increments past
@@ -51,10 +60,11 @@ type TrackOptions struct {
func NewTrackOptions() *TrackOptions {
return &TrackOptions{
Timeout: 5 * time.Minute,
LogsSince: 10 * time.Minute,
QPS: 100,
Burst: 200,
Timeout: 5 * time.Minute,
LogsInterval: defaultLogsInterval,
LogsSince: 10 * time.Minute,
QPS: 100,
Burst: 200,
}
}
@@ -68,6 +78,18 @@ func (o *TrackOptions) WithLogs(logs bool) *TrackOptions {
return o
}
// WithLogsInterval sets how often captured logs are printed.
func (o *TrackOptions) WithLogsInterval(interval time.Duration) *TrackOptions {
o.LogsInterval = interval
return o
}
// WithLogsUntilReady limits pod log output to startup when log streaming is enabled.
func (o *TrackOptions) WithLogsUntilReady(v bool) *TrackOptions {
o.LogsUntilReady = v
return o
}
func (o *TrackOptions) WithFilterConfig(config *resource.FilterConfig) *TrackOptions {
o.Filter = config
return o
+20 -15
View File
@@ -3,6 +3,7 @@ package kubedog
import (
"context"
"fmt"
"maps"
"sort"
"strings"
"sync"
@@ -18,7 +19,6 @@ import (
const (
progressInterval = 10 * time.Second
logsInterval = 10 * time.Second
heartbeatInterval = 2 * time.Minute
)
@@ -60,7 +60,7 @@ func HeaderDividerStyled(title string, useColor bool) string {
// trailing padding on left-aligned last columns.
func TableVisualWidth(tableStr string) int {
max := 0
for _, line := range strings.Split(tableStr, "\n") {
for line := range strings.SplitSeq(tableStr, "\n") {
line = strings.TrimRight(line, " ")
w := runewidth.StringWidth(line)
if w > max {
@@ -136,9 +136,7 @@ func (g *gateStatuses) snapshot() map[string]string {
g.mu.RLock()
defer g.mu.RUnlock()
out := make(map[string]string, len(g.m))
for k, v := range g.m {
out[k] = v
}
maps.Copy(out, g.m)
return out
}
@@ -189,9 +187,11 @@ type progressPrinter struct {
logStore *kdutil.Concurrent[*logstore.LogStore]
skipLogs bool
failedLogsOnly bool
podLogFilter *podLogFilter
gates *gateStatuses
skipped *skippedKeys
useColor bool
logsInterval time.Duration
startTime time.Time
// lastEmit tracks the most recent time we printed *anything* to the
// logger (progress block or log stream). The heartbeat ticker uses it to
@@ -207,6 +207,9 @@ type progressPrinter struct {
lastLogSource string
}
// newProgressPrinter builds the printer that renders progress blocks, log
// streams, and heartbeats. logsInterval sets the log-flush cadence; a zero
// value falls back to defaultLogsInterval.
func newProgressPrinter(
logger *zap.SugaredLogger,
releaseName string,
@@ -217,7 +220,11 @@ func newProgressPrinter(
gates *gateStatuses,
skipped *skippedKeys,
useColor bool,
logsInterval time.Duration,
) *progressPrinter {
if logsInterval <= 0 {
logsInterval = defaultLogsInterval
}
return &progressPrinter{
logger: logger,
releaseName: releaseName,
@@ -228,6 +235,7 @@ func newProgressPrinter(
gates: gates,
skipped: skipped,
useColor: useColor,
logsInterval: logsInterval,
startTime: time.Now(),
lastEmit: time.Now(),
lastStatus: make(map[string]string),
@@ -238,7 +246,7 @@ func newProgressPrinter(
func (p *progressPrinter) run(ctx context.Context, done <-chan struct{}) {
progressTicker := time.NewTicker(progressInterval)
defer progressTicker.Stop()
logsTicker := time.NewTicker(logsInterval)
logsTicker := time.NewTicker(p.logsInterval)
defer logsTicker.Stop()
heartbeatTicker := time.NewTicker(heartbeatInterval)
defer heartbeatTicker.Stop()
@@ -249,9 +257,6 @@ func (p *progressPrinter) run(ctx context.Context, done <-chan struct{}) {
select {
case <-progressTicker.C:
p.flushProgress()
if !p.skipLogs {
p.flushLogs()
}
case <-logsTicker.C:
if !p.skipLogs {
p.flushLogs()
@@ -259,10 +264,6 @@ func (p *progressPrinter) run(ctx context.Context, done <-chan struct{}) {
case <-heartbeatTicker.C:
p.flushHeartbeat()
case <-done:
p.flushProgress()
if !p.skipLogs {
p.flushLogs()
}
return
case <-ctx.Done():
return
@@ -327,8 +328,8 @@ func (p *progressPrinter) statusColor(status, parentKind string) string {
}
head := status
if idx := strings.Index(status, " "); idx >= 0 {
head = status[:idx]
if before, _, ok := strings.Cut(status, " "); ok {
head = before
}
switch head {
case "ready":
@@ -726,6 +727,7 @@ func (p *progressPrinter) flushLogs() {
return
}
}
cutoff := p.podLogFilter.cutoff(rl.Name(), rl.Namespace(), rl.GroupVersionKind())
for source, lines := range rl.LogLines() {
cursorKey := resourceKey + "|" + source
start := p.lastCounts[cursorKey]
@@ -733,6 +735,9 @@ func (p *progressPrinter) flushLogs() {
continue
}
for _, ll := range lines[start:] {
if !cutoff.IsZero() && ll.Time.After(cutoff) {
continue
}
pending = append(pending, entry{
resourceKey: resourceKey,
displayLabel: displayLabel,
+161 -23
View File
@@ -15,6 +15,7 @@ import (
kdutil "github.com/werf/kubedog/pkg/trackers/dyntracker/util"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
@@ -211,7 +212,7 @@ func TestFlushProgress_HidesStaleReadyPodWithoutAttr(t *testing.T) {
taskStore.RWTransaction(func(s *statestore.TaskStore) { s.AddReadinessTaskState(taskC) })
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true /*skipLogs*/, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, true /*skipLogs*/, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
@@ -238,7 +239,7 @@ func TestFlushProgress_PreReadyPhaseAttributeOverridesReady(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
@@ -266,7 +267,7 @@ func TestFlushProgress_KeepsNamespacesWhenReleaseSpansMultiple(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
@@ -292,7 +293,7 @@ func TestFlushProgress_HidesEmptyNameChildPlaceholder(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
@@ -314,7 +315,7 @@ func TestFlushProgress_HeaderUsesReleaseName(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
assert.Contains(t, out, "Release 'vray' progress (",
@@ -334,7 +335,7 @@ func TestFlushProgress_HeaderFallsBackWithoutReleaseName(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
assert.Contains(t, out, "kubedog progress (")
@@ -356,7 +357,7 @@ func TestFlushProgress_NestingAndAlignment(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
@@ -392,7 +393,7 @@ func TestFlushProgress_RespectsGateAndSkip(t *testing.T) {
skips.add(kdutil.ResourceID("skipped", "ns", deploymentGVK))
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, gates, skips, false)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, gates, skips, false, 0)
p.flushProgress()
out := capturedOutput(buf, mu)
@@ -420,7 +421,7 @@ func TestFlushLogs_FailedOnlyMode_GatesUnchanged(t *testing.T) {
addPodLogs(t, logStore, "init-bad", "bad line 1", "panic: boom")
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false /*skipLogs*/, true /*failedLogsOnly*/, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, false /*skipLogs*/, true /*failedLogsOnly*/, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushLogs()
out := capturedOutput(buf, mu)
@@ -445,7 +446,7 @@ func TestFlushLogs_FailedOnlyMode_DoesNotAdvanceCursorForSuccess(t *testing.T) {
addPodLogs(t, logStore, "flaky-pod", "early line A", "early line B")
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false, true, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, false, true, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushLogs()
assert.NotContains(t, capturedOutput(buf, mu), "early line",
@@ -474,7 +475,7 @@ func TestFlushLogs_AllPodsMode_EmitsEverything(t *testing.T) {
addPodLogs(t, logStore, "init-bad", "bad line 1")
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false /*failedLogsOnly=off*/, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false /*failedLogsOnly=off*/, newGateStatuses(), newSkippedKeys(), false, 0)
p.flushLogs()
out := capturedOutput(buf, mu)
@@ -493,7 +494,7 @@ func TestFlushLogs_HeaderDedupedAcrossFlushes(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false, newGateStatuses(), newSkippedKeys(), false, 0)
addPodLogs(t, logStore, "app-pod", "line 1", "line 2")
p.flushLogs()
@@ -511,6 +512,94 @@ func TestFlushLogs_HeaderDedupedAcrossFlushes(t *testing.T) {
assert.Contains(t, out, "line 4")
}
func TestFlushLogs_LogsUntilReady(t *testing.T) {
for _, kind := range []string{"Deployment", "StatefulSet", "DaemonSet"} {
t.Run(kind, func(t *testing.T) {
taskStore := kdutil.NewConcurrent(statestore.NewTaskStore())
logStore := kdutil.NewConcurrent(logstore.NewLogStore())
gvk := schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: kind}
ts := statestore.NewReadinessTaskState("app", "ns", gvk, statestore.ReadinessTaskStateOptions{})
// The real dyntracker leaves individual pod states unknown until
// the entire workload is ready, even when a replica is already Ready.
addPodChild(t, ts, "app-ready", "ns", statestore.ResourceStatusUnknown, "Running")
addPodChild(t, ts, "app-starting", "ns", statestore.ResourceStatusUnknown, "Running")
taskStore.RWTransaction(func(s *statestore.TaskStore) {
s.AddReadinessTaskState(kdutil.NewConcurrent(ts))
})
readyTime := time.Unix(100, 500)
filter := newPodLogFilter()
filter.update(newLogFilterPod("app-ready", "ns", "ready-uid", "True", readyTime))
filter.update(newLogFilterPod("app-starting", "ns", "starting-uid", "False", readyTime))
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.podLogFilter = filter
readyLogs := kdutil.NewConcurrent(logstore.NewResourceLogs("app-ready", "ns", podGVK))
startingLogs := kdutil.NewConcurrent(logstore.NewResourceLogs("app-starting", "ns", podGVK))
logStore.RWTransaction(func(s *logstore.LogStore) {
s.AddResourceLogs(readyLogs)
s.AddResourceLogs(startingLogs)
})
readyLogs.RWTransaction(func(rl *logstore.ResourceLogs) {
rl.AddLogLine("successful startup", "container/main", readyTime.Add(-time.Second))
rl.AddLogLine("at readiness", "container/main", readyTime)
rl.AddLogLine("HTTP request", "container/main", readyTime.Add(time.Second))
})
startingLogs.RWTransaction(func(rl *logstore.ResourceLogs) {
rl.AddLogLine("still starting", "container/main", readyTime.Add(time.Second))
})
p.flushLogs()
// Late pre-readiness chunks from another container still flush;
// repeated traffic from the ready replica stays silent.
readyLogs.RWTransaction(func(rl *logstore.ResourceLogs) {
rl.AddLogLine("late startup chunk", "container/sidecar", readyTime.Add(-time.Millisecond))
rl.AddLogLine("background job", "container/sidecar", readyTime.Add(time.Second))
rl.AddLogLine("another HTTP request", "container/main", readyTime.Add(2*time.Second))
})
p.flushLogs()
out := capturedOutput(buf, mu)
assert.Contains(t, out, "successful startup")
assert.Contains(t, out, "at readiness")
assert.Contains(t, out, "late startup chunk")
assert.Contains(t, out, "still starting")
assert.NotContains(t, out, "HTTP request")
assert.NotContains(t, out, "background job")
assert.Equal(t, 4, p.lastCounts["Pod/ns/app-ready|container/main"])
assert.Equal(t, 2, p.lastCounts["Pod/ns/app-ready|container/sidecar"])
p.flushLogs()
assert.Equal(t, out, capturedOutput(buf, mu), "startup lines must not be duplicated")
// Filtering logs must not mutate readiness or hide a later failure.
assert.NotEqual(t, statestore.ReadinessTaskStatusReady, ts.Status())
flipPodPhase(t, ts, "app-ready", "ns", "CrashLoopBackOff")
p.flushProgress()
assert.Contains(t, capturedOutput(buf, mu), "CrashLoopBackOff")
assert.Contains(t, p.collectFailedPodIDs(), kdutil.ResourceID("app-ready", "ns", podGVK))
})
}
}
func TestFlushLogs_LogsUntilReady_JobLogs(t *testing.T) {
taskStore := kdutil.NewConcurrent(statestore.NewTaskStore())
logStore := kdutil.NewConcurrent(logstore.NewLogStore())
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.podLogFilter = newPodLogFilter()
pod := newLogFilterPod("job-pod", "ns", "job-uid", "True", time.Unix(0, 0))
pod.SetOwnerReferences([]metav1.OwnerReference{{Kind: "Job", Name: "job"}})
p.podLogFilter.update(pod)
addPodLogs(t, logStore, "job-pod", "job started", "job still running")
p.flushLogs()
addPodLogs(t, logStore, "job-pod", "job completed")
p.flushLogs()
out := capturedOutput(buf, mu)
assert.Contains(t, out, "job started")
assert.Contains(t, out, "job still running")
assert.Contains(t, out, "job completed")
}
func TestHeaderDivider_FormatsBothBorders(t *testing.T) {
assert.Equal(t, "========== title: ==========", HeaderDivider("title:"))
}
@@ -625,7 +714,7 @@ func TestFlushHeartbeat_EmitsWhenIdleWithInFlight(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
// Simulate the silent gap: pretend we last printed long enough ago that
// the heartbeat ticker would now fire.
p.lastEmit = time.Now().Add(-3 * heartbeatInterval)
@@ -664,7 +753,7 @@ func TestFlushHeartbeat_LabelsGatedTasksAsWaiting(t *testing.T) {
gates.set(BaselineKey("job", "ns", "queued-job"), "waiting for update (uid=abc gen=1)")
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, gates, newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, gates, newSkippedKeys(), false, 0)
p.lastEmit = time.Now().Add(-3 * heartbeatInterval)
p.flushHeartbeat()
@@ -705,7 +794,7 @@ func TestFlushHeartbeat_ProgressingItemsListedBeforeWaiting(t *testing.T) {
gates.set(BaselineKey("job", "ns", "bbb-queued"), "waiting for update (uid=b gen=1)")
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, gates, newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, gates, newSkippedKeys(), false, 0)
p.lastEmit = time.Now().Add(-3 * heartbeatInterval)
p.flushHeartbeat()
@@ -738,7 +827,7 @@ func TestFlushHeartbeat_AllWaitingShowsCountOnly(t *testing.T) {
}
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, gates, newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, gates, newSkippedKeys(), false, 0)
p.lastEmit = time.Now().Add(-3 * heartbeatInterval)
p.flushHeartbeat()
@@ -768,7 +857,7 @@ func TestFlushHeartbeat_DoesNotUpdateLastEmit(t *testing.T) {
})
logger, _, _ := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
pinned := time.Now().Add(-3 * heartbeatInterval)
p.lastEmit = pinned
@@ -789,7 +878,7 @@ func TestFlushHeartbeat_SuppressedWhenRecentEmit(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
// lastEmit is current — we just printed something, so the heartbeat
// should stay quiet.
p.lastEmit = time.Now()
@@ -816,7 +905,7 @@ func TestFlushHeartbeat_SuppressedWhenAllReady(t *testing.T) {
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.lastEmit = time.Now().Add(-3 * heartbeatInterval)
p.flushHeartbeat()
@@ -841,7 +930,7 @@ func TestFlushHeartbeat_CapsResourceListAndShowsOverflow(t *testing.T) {
}
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "vray", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
p.lastEmit = time.Now().Add(-3 * heartbeatInterval)
p.flushHeartbeat()
@@ -855,12 +944,12 @@ func TestFlushHeartbeat_CapsResourceListAndShowsOverflow(t *testing.T) {
"progressing items beyond the cap must be summarized by overflow count, not listed")
}
func TestProgressPrinter_FullRun_CancelsAndDrainsOnContextDone(t *testing.T) {
// Smoke test that the run loop terminates cleanly on context cancel.
func TestProgressPrinter_FullRun_StopsOnContextDone(t *testing.T) {
// The tracker performs the final drain after this loop has stopped.
taskStore := kdutil.NewConcurrent(statestore.NewTaskStore())
logStore := kdutil.NewConcurrent(logstore.NewLogStore())
logger, _, _ := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false)
p := newProgressPrinter(logger, "", taskStore, logStore, true, false, newGateStatuses(), newSkippedKeys(), false, 0)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
@@ -877,6 +966,55 @@ func TestProgressPrinter_FullRun_CancelsAndDrainsOnContextDone(t *testing.T) {
}
}
func TestProgressPrinter_RunFlushesLogsAtConfiguredInterval(t *testing.T) {
taskStore := kdutil.NewConcurrent(statestore.NewTaskStore())
logStore := kdutil.NewConcurrent(logstore.NewLogStore())
ts := statestore.NewReadinessTaskState("app", "ns", deploymentGVK, statestore.ReadinessTaskStateOptions{})
taskStore.RWTransaction(func(s *statestore.TaskStore) {
s.AddReadinessTaskState(kdutil.NewConcurrent(ts))
})
logger, buf, mu := newBufferedLogger(t)
p := newProgressPrinter(logger, "", taskStore, logStore, false, false, newGateStatuses(), newSkippedKeys(), false, 20*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
returned := make(chan struct{})
go func() {
p.run(ctx, done)
close(returned)
}()
t.Cleanup(func() {
cancel()
select {
case <-returned:
case <-time.After(2 * time.Second):
t.Error("printer.run did not return after ctx cancel")
}
})
addPodLogs(t, logStore, "app-pod", "interval-line-one")
require.Eventually(t, func() bool {
return strings.Contains(capturedOutput(buf, mu), "interval-line-one")
}, 2*time.Second, 5*time.Millisecond, "first line should appear before tracking finishes")
// Seeing the second line proves that another logs tick has occurred.
addPodLogs(t, logStore, "app-pod", "interval-line-two")
require.Eventually(t, func() bool {
return strings.Contains(capturedOutput(buf, mu), "interval-line-two")
}, 2*time.Second, 5*time.Millisecond)
out := capturedOutput(buf, mu)
assert.Equal(t, 1, strings.Count(out, "interval-line-one"), "a later tick must not repeat earlier lines")
assert.Equal(t, 1, strings.Count(out, "interval-line-two"))
assert.Equal(t, 1, strings.Count(out, "kubedog progress ("), "log ticks must not print extra progress blocks")
select {
case <-returned:
t.Fatal("printer.run returned before tracking finished")
default:
}
}
// addPodLogs appends log lines to a pod's ResourceLogs entry in the log
// store, creating the entry if needed. Uses an incrementing timestamp so
// the printer's chronological sort is deterministic.
+17 -8
View File
@@ -429,15 +429,11 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso
}()
captureLogsFromTime := time.Now().Add(-t.trackOptions.LogsSince)
// Capture logs whenever either flag wants them. The kubedog tracker
// records logs into the logStore unconditionally; the failed-only mode
// is enforced at print time by the printer.
// Capture logs for either output mode. Failed-only filtering happens in
// the printer, after kubedog has populated the log store.
wantLogsCapture := t.trackOptions.Logs || t.trackOptions.FailedLogsOnly
ignoreLogs := !wantLogsCapture
// failedLogsOnly takes effect only when full streaming isn't already on.
failedLogsOnly := t.trackOptions.FailedLogsOnly && !t.trackOptions.Logs
// skipLogsInPrinter mutes the printer entirely if neither mode is on.
skipLogsInPrinter := !wantLogsCapture
type trackerEntry struct {
target trackTarget
@@ -456,7 +452,13 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso
}
gateStatuses := newGateStatuses()
printer := newProgressPrinter(t.logger, t.releaseName, taskStore, logStore, skipLogsInPrinter, failedLogsOnly, gateStatuses, t.skipped, t.trackOptions.Color)
printer := newProgressPrinter(t.logger, t.releaseName, taskStore, logStore, ignoreLogs,
failedLogsOnly, gateStatuses, t.skipped, t.trackOptions.Color, t.trackOptions.LogsInterval)
podLogFilter, err := newStartupLogFilter(t.trackOptions, informerFactory, targets)
if err != nil {
return err
}
printer.podLogFilter = podLogFilter
printerDone := make(chan struct{})
// Spawn a parallel failure watchdog. It catches pods that genuinely
@@ -553,7 +555,7 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso
if t.releaseName != "" {
releaseLabel = fmt.Sprintf("release '%s'", t.releaseName)
}
t.logger.Warnf("kubedog tracker stopped for %s: %v — helmfile will continue waiting for helm to finish; no more progress/log output until then",
t.logger.Warnf("kubedog tracker stopped for %s: %v — helmfile will continue waiting for helm to finish; no more periodic progress/log output until then",
releaseLabel, err)
cancel()
case <-trackersDone:
@@ -564,6 +566,13 @@ func (t *Tracker) TrackResources(ctx context.Context, resources []*resource.Reso
cancel()
<-trackersDone
<-printerDone
// Flush after all producers and the periodic printer have stopped. This
// guarantees the last lines are printed even if cancellation wins the
// printer's select over trackersDone.
printer.flushProgress()
if !ignoreLogs {
printer.flushLogs()
}
// Drain remaining tracker errors so they're surfaced in logs.
close(errCh)
+18 -1
View File
@@ -32,7 +32,9 @@ func TestNewTrackOptions(t *testing.T) {
assert.NotNil(t, opts)
assert.Equal(t, 5*time.Minute, opts.Timeout)
assert.Equal(t, false, opts.Logs)
assert.False(t, opts.LogsUntilReady)
assert.Equal(t, 10*time.Minute, opts.LogsSince)
assert.Equal(t, 10*time.Second, opts.LogsInterval)
}
func TestTrackOptions_WithTimeout(t *testing.T) {
@@ -49,6 +51,19 @@ func TestTrackOptions_WithLogs(t *testing.T) {
assert.True(t, opts.Logs)
}
func TestTrackOptions_WithLogsInterval(t *testing.T) {
opts := NewTrackOptions()
opts = opts.WithLogsInterval(3 * time.Second)
assert.Equal(t, 3*time.Second, opts.LogsInterval)
}
func TestTrackOptions_WithLogsUntilReady(t *testing.T) {
opts := NewTrackOptions().WithLogs(true).WithLogsUntilReady(true)
assert.True(t, opts.LogsUntilReady)
assert.True(t, opts.Logs)
}
func TestTrackOptions_Chaining(t *testing.T) {
opts := NewTrackOptions()
opts = opts.
@@ -375,7 +390,8 @@ func TestTrackOptions_Chaining_AllSetters(t *testing.T) {
WithQPS(50).
WithBurst(80).
WithColor(true).
WithBaselines(baselines)
WithBaselines(baselines).
WithLogsInterval(30 * time.Second)
assert.Equal(t, 2*time.Minute, opts.Timeout)
assert.True(t, opts.Logs)
@@ -385,6 +401,7 @@ func TestTrackOptions_Chaining_AllSetters(t *testing.T) {
assert.Equal(t, 80, opts.Burst)
assert.True(t, opts.Color)
assert.Equal(t, baselines, opts.Baselines)
assert.Equal(t, 30*time.Second, opts.LogsInterval)
}
// makeObj builds an *unstructured.Unstructured with the given path/value
+5 -9
View File
@@ -3,6 +3,7 @@ package kubedog
import (
"context"
"fmt"
"slices"
"time"
"github.com/werf/kubedog/pkg/trackers/dyntracker/statestore"
@@ -167,10 +168,8 @@ func (t *Tracker) scanForMissedFailures(ctx context.Context, taskStore *kdutil.C
// "RS UIDs owned by this Deployment" so we only LIST RSes when we actually
// need to disambiguate.
func podOwnedBy(pod *unstructured.Unstructured, workloadUID types.UID, workloadKind string, deploymentRSUIDs func() map[string]struct{}) bool {
for _, uid := range podOwnerUIDs(pod) {
if uid == workloadUID {
return true
}
if slices.Contains(podOwnerUIDs(pod), workloadUID) {
return true
}
if workloadKind != "deploy" {
return false
@@ -206,11 +205,8 @@ func (t *Tracker) rsUIDsOwnedBy(ctx context.Context, namespace string, workloadU
out := map[string]struct{}{}
for i := range rsList.Items {
rs := &rsList.Items[i]
for _, uid := range podOwnerUIDs(rs) {
if uid == workloadUID {
out[string(rs.GetUID())] = struct{}{}
break
}
if slices.Contains(podOwnerUIDs(rs), workloadUID) {
out[string(rs.GetUID())] = struct{}{}
}
}
return out
+9 -13
View File
@@ -2,6 +2,7 @@ package maputil
import (
"fmt"
"maps"
"strconv"
"strings"
)
@@ -263,16 +264,14 @@ func typedVal(val string, st bool) any {
}
// MergeMaps merges two maps with special handling for nested maps and arrays.
func MergeMaps(a, b map[string]interface{}, opts ...MergeOptions) map[string]interface{} {
func MergeMaps(a, b map[string]any, opts ...MergeOptions) map[string]any {
arrayStrategy := ArrayMergeStrategySparse
if len(opts) > 0 {
arrayStrategy = opts[0].ArrayStrategy
}
out := make(map[string]interface{}, len(a))
for k, v := range a {
out[k] = v
}
out := make(map[string]any, len(a))
maps.Copy(out, a)
for k, v := range b {
if v == nil {
// If key doesn't exist in base, add nil (issue #1154).
@@ -282,9 +281,9 @@ func MergeMaps(a, b map[string]interface{}, opts ...MergeOptions) map[string]int
}
continue
}
if v, ok := v.(map[string]interface{}); ok {
if v, ok := v.(map[string]any); ok {
if bv, ok := out[k]; ok {
if bv, ok := bv.(map[string]interface{}); ok {
if bv, ok := bv.(map[string]any); ok {
out[k] = MergeMaps(bv, v, opts...)
continue
}
@@ -365,15 +364,12 @@ func mergeSlices(base, override []any, strategy ArrayMergeStrategy) []any {
}
// Merge element-by-element (for ArrayMergeStrategyMerge or sparse arrays)
maxLen := len(base)
if len(override) > maxLen {
maxLen = len(override)
}
maxLen := max(len(override), len(base))
result := make([]interface{}, maxLen)
result := make([]any, maxLen)
copy(result, base)
for i := 0; i < len(override); i++ {
for i := range override {
overrideVal := override[i]
if overrideVal == nil {
continue
+36 -36
View File
@@ -195,27 +195,27 @@ func typedValueTest(t *testing.T, input string, expectedWhenNoStr any) {
}
func TestMapUtil_MergeMaps(t *testing.T) {
map1 := map[string]interface{}{
map1 := map[string]any{
"debug": true,
}
map2 := map[string]interface{}{
map2 := map[string]any{
"logLevel": "info",
"replicaCount": 3,
}
map3 := map[string]interface{}{
map3 := map[string]any{
"logLevel": "info",
"replicaCount": map[string]any{
"app1": 3,
"awesome": 4,
},
}
map4 := map[string]interface{}{
map4 := map[string]any{
"logLevel": "info",
"replicaCount": map[string]any{
"app1": 3,
},
}
map5 := map[string]interface{}{
map5 := map[string]any{
"logLevel": "error",
"replicaCount": nil,
}
@@ -239,7 +239,7 @@ func TestMapUtil_MergeMaps(t *testing.T) {
}
testMap = MergeMaps(map1, map3)
expectedMap := map[string]interface{}{
expectedMap := map[string]any{
"debug": true,
"logLevel": "info",
"replicaCount": map[string]any{
@@ -253,7 +253,7 @@ func TestMapUtil_MergeMaps(t *testing.T) {
}
testMap = MergeMaps(map3, map5)
expectedMap = map[string]interface{}{
expectedMap = map[string]any{
"logLevel": "error",
"replicaCount": map[string]any{
"app1": 3,
@@ -478,7 +478,7 @@ func TestMapUtil_Issue2281_EmptyMapScenario(t *testing.T) {
func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
t.Run("merging sparse arrays should preserve elements from base that aren't in override", func(t *testing.T) {
// Base values from helmfile
base := map[string]interface{}{
base := map[string]any{
"top": map[string]any{
"array": []any{"thing1", "thing2"},
},
@@ -486,7 +486,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
// Override values from --state-values-set top.array[1]=cmdlinething1
// This creates a sparse array with nil at index 0
override := map[string]interface{}{
override := map[string]any{
"top": map[string]any{
"array": []any{nil, "cmdlinething1"},
},
@@ -506,7 +506,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
t.Run("complete arrays without nils should replace entirely (layer behavior)", func(t *testing.T) {
// Base values from helmfile
base := map[string]interface{}{
base := map[string]any{
"top": map[string]any{
"array": []any{"thing1", "thing2", "thing3"},
},
@@ -514,7 +514,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
// Override values from environment YAML (complete array, no nils)
// This should REPLACE the base array entirely
override := map[string]interface{}{
override := map[string]any{
"top": map[string]any{
"array": []any{"override1"},
},
@@ -533,7 +533,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
t.Run("merging complex arrays should preserve non-overridden elements and fields", func(t *testing.T) {
// Base values from helmfile
base := map[string]interface{}{
base := map[string]any{
"top": map[string]any{
"complexArray": []any{
map[string]any{
@@ -549,7 +549,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
}
// Override values from --state-values-set top.complexArray[1].anotherThing=cmdline
override := map[string]interface{}{
override := map[string]any{
"top": map[string]any{
"complexArray": []any{
nil,
@@ -588,7 +588,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
t.Run("complete issue #2281 scenario with MergeMaps - sparse arrays", func(t *testing.T) {
// Base values from helmfile
base := map[string]interface{}{
base := map[string]any{
"top": map[string]any{
"array": []any{"thing1", "thing2"},
"complexArray": []any{
@@ -607,7 +607,7 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
// Override values from:
// --state-values-set top.array[1]=cmdlinething1 (creates sparse array with nil at 0)
// --state-values-set top.complexArray[1].anotherThing=cmdline
override := map[string]interface{}{
override := map[string]any{
"top": map[string]any{
"array": []any{nil, "cmdlinething1"}, // Sparse array - nil at index 0
"complexArray": []any{
@@ -651,10 +651,10 @@ func TestMapUtil_Issue2281_MergeArrays(t *testing.T) {
func TestMergeMaps_ArrayStrategies(t *testing.T) {
t.Run("ArrayMergeStrategyReplace should replace arrays entirely - fixes #2353", func(t *testing.T) {
// This simulates layer value overriding where outer layer array should replace inner
base := map[string]interface{}{
base := map[string]any{
"array": []any{"inner1", "inner2", "inner3"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{"outer1", "outer2"},
}
@@ -671,10 +671,10 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
t.Run("ArrayMergeStrategySparse (default) should merge element-by-element - preserves #2281 fix", func(t *testing.T) {
// This simulates --state-values-set which creates sparse arrays
base := map[string]interface{}{
base := map[string]any{
"array": []any{"base1", "base2", "base3"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{nil, "override2"}, // Has nil = sparse array from CLI
}
@@ -691,10 +691,10 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
t.Run("Auto-detect: complete array (no nils) replaces base entirely", func(t *testing.T) {
// Array without nils is detected as "complete" (layer value) and replaces entirely
base := map[string]interface{}{
base := map[string]any{
"array": []any{"base1", "base2", "base3"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{"override1"}, // Single element, no nils
}
@@ -711,10 +711,10 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
t.Run("Auto-detect: sparse array (with nils) preserves base at nil indices", func(t *testing.T) {
// Array with nils is detected as "sparse" (CLI value) and merges element-by-element
base := map[string]interface{}{
base := map[string]any{
"array": []any{"base1", "base2", "base3"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{nil, nil, "override3"}, // Has nils at indices 0, 1
}
@@ -730,13 +730,13 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
})
t.Run("nested maps in sparse arrays should merge recursively", func(t *testing.T) {
base := map[string]interface{}{
base := map[string]any{
"complexArray": []any{
map[string]any{"field1": "a", "field2": "b"},
map[string]any{"field1": "c", "field2": "d"},
},
}
override := map[string]interface{}{
override := map[string]any{
"complexArray": []any{
nil, // Skip index 0
map[string]any{"field2": "override"}, // Only override field2 at index 1
@@ -761,14 +761,14 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
t.Run("Replace strategy: array of maps replaced entirely - layer scenario #2353", func(t *testing.T) {
// This is the key scenario from #2353: outer layer defining complete array of objects
base := map[string]interface{}{
base := map[string]any{
"releases": []any{
map[string]any{"name": "inner-release-1", "chart": "inner-chart-1"},
map[string]any{"name": "inner-release-2", "chart": "inner-chart-2"},
map[string]any{"name": "inner-release-3", "chart": "inner-chart-3"},
},
}
override := map[string]interface{}{
override := map[string]any{
"releases": []any{
map[string]any{"name": "outer-release-1", "chart": "outer-chart-1"},
map[string]any{"name": "outer-release-2", "chart": "outer-chart-2"},
@@ -796,10 +796,10 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
})
t.Run("Replace strategy: empty override array replaces base", func(t *testing.T) {
base := map[string]interface{}{
base := map[string]any{
"array": []any{"a", "b", "c"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{}, // Empty array
}
@@ -814,10 +814,10 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
})
t.Run("Auto-detect: empty override replaces base (no nils means complete)", func(t *testing.T) {
base := map[string]interface{}{
base := map[string]any{
"array": []any{"a", "b", "c"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{}, // Empty array - has no nils, detected as complete
}
@@ -833,14 +833,14 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
})
t.Run("strategies propagate to nested maps", func(t *testing.T) {
base := map[string]interface{}{
base := map[string]any{
"outer": map[string]any{
"inner": []any{"a", "b", "c"},
},
}
// With Replace strategy - explicit replacement
overrideComplete := map[string]interface{}{
overrideComplete := map[string]any{
"outer": map[string]any{
"inner": []any{"x", "y"}, // Complete array (no nils)
},
@@ -856,7 +856,7 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
}
// With auto-detection and sparse array (has nils)
overrideSparse := map[string]interface{}{
overrideSparse := map[string]any{
"outer": map[string]any{
"inner": []any{"x", "y", nil}, // Sparse array - has nil at index 2
},
@@ -875,10 +875,10 @@ func TestMergeMaps_ArrayStrategies(t *testing.T) {
t.Run("ArrayMergeStrategyMerge always merges element-by-element (CLI index 0 case)", func(t *testing.T) {
// This tests the CLI scenario: --state-values-set array[0]=value
// Creates array ["value"] with NO nils, but should still merge
base := map[string]interface{}{
base := map[string]any{
"array": []any{"base0", "base1", "base2"},
}
override := map[string]interface{}{
override := map[string]any{
"array": []any{"override0"}, // Single element, no nils - from CLI index 0
}
+111 -2
View File
@@ -1,9 +1,12 @@
package plugins
import (
"errors"
"fmt"
"io"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@@ -18,9 +21,101 @@ const (
valsCacheSize = 512
)
var instance *vals.Runtime
var instance vals.Evaluator
var mu sync.Mutex
// ErrValsDisabled is returned by the evaluator when HELMFILE_DISABLE_VALS_STRICT is set
// and a `ref+` expression is encountered.
var ErrValsDisabled = errors.New("vals is disabled via HELMFILE_DISABLE_VALS_STRICT environment variable")
// refPlusRegexp mirrors the reference syntax understood by the vals library
// (`ref+<provider>://...` and `secretref+<provider>://...`), so that disabled-vals
// modes only report values that vals itself would have tried to resolve.
var refPlusRegexp = regexp.MustCompile(`(secret)?ref\+[^\s+:]*://`)
// passthroughEvaluator passes values through unchanged (for external vals)
type passthroughEvaluator struct{}
func (p *passthroughEvaluator) Eval(m map[string]any) (map[string]any, error) {
return normalizeMap(m), nil
}
// strictEvaluator passes through values but errors if ref+ is detected
type strictEvaluator struct{}
func (s *strictEvaluator) Eval(m map[string]any) (map[string]any, error) {
if containsRefPlus(m) {
return nil, ErrValsDisabled
}
return normalizeMap(m), nil
}
// normalizeMap converts []string values to []any to match vals.Eval behavior.
func normalizeMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = normalizeValue(v)
}
return out
}
// normalizeValue recursively converts []string to []any and map[any]any to
// map[string]any, matching the type normalization performed by vals.Eval.
func normalizeValue(v any) any {
switch typed := v.(type) {
case map[string]any:
return normalizeMap(typed)
case map[any]any:
strmap := make(map[string]any, len(typed))
for k, v := range typed {
strmap[fmt.Sprintf("%v", k)] = normalizeValue(v)
}
return strmap
case []any:
a := make([]any, len(typed))
for i, e := range typed {
a[i] = normalizeValue(e)
}
return a
case []string:
a := make([]any, len(typed))
for i, s := range typed {
a[i] = s
}
return a
default:
return v
}
}
func containsRefPlus(v any) bool {
switch val := v.(type) {
case string:
return refPlusRegexp.MatchString(val)
case map[string]any:
for _, v := range val {
if containsRefPlus(v) {
return true
}
}
case map[any]any:
for _, v := range val {
if containsRefPlus(v) {
return true
}
}
case []any:
if slices.ContainsFunc(val, containsRefPlus) {
return true
}
case []string:
if slices.ContainsFunc(val, refPlusRegexp.MatchString) {
return true
}
}
return false
}
func buildValsOptions() (vals.Options, error) {
// Configure AWS SDK logging via HELMFILE_AWS_SDK_LOG_LEVEL environment variable
// Default: "off" to prevent sensitive information (tokens, auth headers) from being exposed
@@ -78,7 +173,7 @@ func buildValsOptions() (vals.Options, error) {
return opts, nil
}
func ValsInstance() (*vals.Runtime, error) {
func ValsInstance() (vals.Evaluator, error) {
mu.Lock()
defer mu.Unlock()
@@ -86,6 +181,20 @@ func ValsInstance() (*vals.Runtime, error) {
return instance, nil
}
// HELMFILE_DISABLE_VALS_STRICT: error on ref+ usage
strict, _ := strconv.ParseBool(os.Getenv(envvar.DisableValsStrict))
if strict {
instance = &strictEvaluator{}
return instance, nil
}
// HELMFILE_DISABLE_VALS: pass-through for external vals
disabled, _ := strconv.ParseBool(os.Getenv(envvar.DisableVals))
if disabled {
instance = &passthroughEvaluator{}
return instance, nil
}
opts, err := buildValsOptions()
if err != nil {
return nil, err
+332 -11
View File
@@ -2,6 +2,7 @@ package plugins
import (
"io"
"os"
"testing"
"github.com/helmfile/vals"
@@ -11,20 +12,347 @@ import (
"github.com/helmfile/helmfile/pkg/envvar"
)
func TestValsInstance(t *testing.T) {
i, err := ValsInstance()
// resetInstance resets the singleton for testing
func resetInstance() {
mu.Lock()
defer mu.Unlock()
instance = nil
}
// setenvForTest sets the environment variable to value, or unsets it when value is empty,
// restoring the original value when the test finishes.
func setenvForTest(t *testing.T, key, value string) {
t.Helper()
if value == "" {
orig, had := os.LookupEnv(key)
os.Unsetenv(key)
t.Cleanup(func() {
if had {
os.Setenv(key, orig)
} else {
os.Unsetenv(key)
}
})
return
}
t.Setenv(key, value)
}
func TestValsInstance(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "")
i, err := ValsInstance()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
i2, _ := ValsInstance()
if i != i2 {
t.Error("Instances should be equal")
}
}
func TestDisableVals(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "true")
setenvForTest(t, envvar.DisableValsStrict, "")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should pass through values unchanged
input := map[string]any{"key": "ref+echo://secret"}
output, err := evaluator.Eval(input)
if err != nil {
t.Fatalf("passthrough should not error: %v", err)
}
if output["key"] != "ref+echo://secret" {
t.Errorf("expected ref+ to pass through unchanged, got %v", output["key"])
}
}
func TestDisableValsStrict(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should error on ref+
input := map[string]any{"key": "ref+echo://secret"}
_, err = evaluator.Eval(input)
if err == nil {
t.Fatal("strict mode should error on ref+")
}
if err != ErrValsDisabled {
t.Errorf("expected ErrValsDisabled, got %v", err)
}
}
func TestDisableValsStrictTakesPrecedence(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "true")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Strict mode should win over pass-through mode
input := map[string]any{"key": "ref+echo://secret"}
_, err = evaluator.Eval(input)
if err != ErrValsDisabled {
t.Errorf("expected ErrValsDisabled when both env vars are set, got %v", err)
}
}
func TestDisableValsStrictAllowsNonRef(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should pass through non-ref+ values, including strings that merely
// contain "ref+" without a provider scheme (not a vals reference)
input := map[string]any{
"key": "normal-value",
"literal": "some ref+ text without scheme",
"secretRef2": "ref+2",
}
output, err := evaluator.Eval(input)
if err != nil {
t.Fatalf("strict mode should allow non-ref+ values: %v", err)
}
if output["key"] != "normal-value" {
t.Errorf("expected value to pass through, got %v", output["key"])
}
}
func TestDisableValsStrictDetectsSecretRef(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// secretref+ is also a vals reference and must be rejected
input := map[string]any{"key": "secretref+vault://secret/data/x#y"}
if _, err := evaluator.Eval(input); err != ErrValsDisabled {
t.Errorf("expected ErrValsDisabled for secretref+ expression, got %v", err)
}
}
func TestDisableValsStrictNestedRef(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should detect nested ref+ in map[string]any
input := map[string]any{
"outer": map[string]any{
"inner": "ref+vault://secret",
},
}
_, err = evaluator.Eval(input)
if err == nil {
t.Fatal("strict mode should detect nested ref+")
}
}
func TestDisableValsStrictMapAnyAny(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should detect ref+ inside map[any]any (yaml.v2 nested maps)
input := map[string]any{
"outer": map[any]any{
"inner": "ref+vault://secret",
},
}
_, err = evaluator.Eval(input)
if err == nil {
t.Fatal("strict mode should detect ref+ in map[any]any")
}
}
func TestDisableValsStrictArrayRef(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should detect ref+ in []any arrays
input := map[string]any{
"values": []any{"normal", "ref+awssecrets://db/password"},
}
_, err = evaluator.Eval(input)
if err == nil {
t.Fatal("strict mode should detect ref+ in arrays")
}
}
func TestDisableValsStrictStringSlice(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "true")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should detect ref+ in []string arrays (matches renderValsSecrets usage)
input := map[string]any{
"values": []string{"normal", "ref+awssecrets://db/password"},
}
_, err = evaluator.Eval(input)
if err == nil {
t.Fatal("strict mode should detect ref+ in []string arrays")
}
}
func TestDisableValsPassThroughStringSlice(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "true")
setenvForTest(t, envvar.DisableValsStrict, "")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
input := map[string]any{
"values": []string{"normal", "ref+awssecrets://db/password"},
}
out, err := evaluator.Eval(input)
if err != nil {
t.Fatalf("pass-through should not error: %v", err)
}
values, ok := out["values"].([]any)
if !ok {
t.Fatalf("expected out[\"values\"] to be []any, got %T", out["values"])
}
if values[0] != "normal" || values[1] != "ref+awssecrets://db/password" {
t.Errorf("unexpected values: %v", values)
}
}
func TestDisableValsPassThroughNestedTypes(t *testing.T) {
resetInstance()
defer resetInstance()
setenvForTest(t, envvar.DisableVals, "true")
setenvForTest(t, envvar.DisableValsStrict, "")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Nested map[any]any and []string should be normalized to map[string]any
// and []any respectively, matching vals.Eval behavior
input := map[string]any{
"outer": map[any]any{
"list": []string{"normal", "ref+echo://secret"},
},
}
out, err := evaluator.Eval(input)
if err != nil {
t.Fatalf("pass-through should not error: %v", err)
}
outer, ok := out["outer"].(map[string]any)
if !ok {
t.Fatalf("expected out[\"outer\"] to be map[string]any, got %T", out["outer"])
}
list, ok := outer["list"].([]any)
if !ok {
t.Fatalf("expected outer[\"list\"] to be []any, got %T", outer["list"])
}
if list[0] != "normal" || list[1] != "ref+echo://secret" {
t.Errorf("unexpected values: %v", list)
}
}
func TestNormalValsProcessing(t *testing.T) {
resetInstance()
defer resetInstance()
// Ensure both are unset
setenvForTest(t, envvar.DisableVals, "")
setenvForTest(t, envvar.DisableValsStrict, "")
evaluator, err := ValsInstance()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// ref+echo should expand to the value after ://
input := map[string]any{"key": "ref+echo://myvalue"}
output, err := evaluator.Eval(input)
if err != nil {
t.Fatalf("normal vals should process ref+echo: %v", err)
}
if output["key"] != "myvalue" {
t.Errorf("expected 'myvalue', got %v", output["key"])
}
}
func TestBuildValsOptions(t *testing.T) {
tests := []struct {
name string
@@ -129,14 +457,6 @@ func TestBuildValsOptions(t *testing.T) {
expectedFailOnMissingKey: false,
expectedLogOutputDiscarded: true,
},
{
name: "aws log level Off mixed case",
awsLogLevel: "Off",
failOnMissingKey: "",
expectedLogLevel: "off",
expectedFailOnMissingKey: false,
expectedLogOutputDiscarded: true,
},
{
name: "both options set",
awsLogLevel: "standard",
@@ -172,6 +492,7 @@ func TestBuildValsOptions(t *testing.T) {
}
}
// TestAWSSDKLogLevelConfiguration tests the AWS SDK log level configuration logic
func TestAWSSDKLogLevelConfiguration(t *testing.T) {
tests := []struct {
name string
+4 -4
View File
@@ -32,7 +32,7 @@ func forbidEnvironmentsWithReleases(filePath string, content []byte) (bool, erro
return true, fmt.Errorf("no top-level config keys are found in %s", filePath)
}
result := []string{}
resultKeys := map[string]interface{}{}
resultKeys := map[string]any{}
for _, k := range topKeys {
if slices.Contains([]string{"environments", "releases", "---"}, k) {
if _, ok := resultKeys[k]; !ok {
@@ -79,9 +79,9 @@ func isTopOrderKey(key string) bool {
// TopKeys returns the top-level config keys.
func TopKeys(helmfileContent []byte, hasSeparator bool) []string {
var topKeys []string
clines := bytes.Split(helmfileContent, []byte("\n"))
clines := bytes.SplitSeq(helmfileContent, []byte("\n"))
for _, line := range clines {
for line := range clines {
lineStr := strings.TrimRightFunc(string(line), unicode.IsSpace)
if lineStr == "" {
continue // Skip empty lines
@@ -91,7 +91,7 @@ func TopKeys(helmfileContent []byte, hasSeparator bool) []string {
}
if topConfigKeysRegex.MatchString(lineStr) {
topKey := strings.SplitN(lineStr, ":", 2)[0]
topKey, _, _ := strings.Cut(lineStr, ":")
topKeys = append(topKeys, topKey)
}
}
+76 -8
View File
@@ -100,6 +100,13 @@ func (e InvalidURLError) Error() string {
type Source struct {
Getter, Scheme, User, Host, Dir, File, RawQuery string
// HasSelector is true when the source URL contained an explicit "@<file>"
// selector marking where the repository/dir root ends and the file
// selector begins. Without it, Dir and File are inferred from the last
// path segment (see Parse), which loses the ability to reconstruct a
// valid getter source when File is itself a pattern.
HasSelector bool
}
func IsRemote(goGetterSrc string) bool {
@@ -112,6 +119,37 @@ func IsRemote(goGetterSrc string) bool {
return true
}
// hasGlobMeta reports whether p contains a filepath.Match metacharacter. The
// set matches filepath.Match's, which also backs local values-file globbing
// (Storage.ExpandPaths), so remote and local glob syntax stay identical.
//
// In practice a raw "?" can never reach p when p is Source.File: url.Parse
// splits the query string at the first unescaped "?", so it never survives
// into u.Path. A "?" wildcard only reaches here percent-encoded ("%3F") in
// the original URL, which url.Parse decodes back into a literal "?" in
// u.Path/u.File. "?" is kept in this set anyway, for symmetry with
// filepath.Match's metacharacters and to handle that percent-encoded case.
func hasGlobMeta(p string) bool {
return strings.ContainsAny(p, `*?[`)
}
// HasGlobPattern reports whether the "@<file>" selector of a go-getter style
// reference is a glob pattern, e.g.
//
// git::https://github.com/org/repo.git@path/to/dir/*.yaml?ref=main
//
// Only the file selector is examined. The rest of a reference legitimately
// contains glob metacharacters that are not patterns: "?" begins the query
// string ("?ref=main") unless percent-encoded, and "[" appears in IPv6 hosts
// and in placeholder values such as github.com/[$GITHUB_ORG]/repo.git.
func HasGlobPattern(goGetterSrc string) bool {
u, err := Parse(goGetterSrc)
if err != nil {
return false
}
return hasGlobMeta(u.File)
}
func Parse(goGetterSrc string) (*Source, error) {
items := strings.Split(goGetterSrc, "::")
var getter string
@@ -143,7 +181,8 @@ func Parse(goGetterSrc string) (*Source, error) {
}
pathComponents := strings.Split(u.Path, "@")
if len(pathComponents) != 2 {
hasSelector := len(pathComponents) == 2
if !hasSelector {
dir := filepath.Dir(u.Path)
if len(dir) > 0 {
dir = dir[1:]
@@ -152,13 +191,14 @@ func Parse(goGetterSrc string) (*Source, error) {
}
return &Source{
Getter: getter,
User: u.User.String(),
Scheme: u.Scheme,
Host: u.Host,
Dir: pathComponents[0],
File: pathComponents[1],
RawQuery: u.RawQuery,
Getter: getter,
User: u.User.String(),
Scheme: u.Scheme,
Host: u.Host,
Dir: pathComponents[0],
File: pathComponents[1],
RawQuery: u.RawQuery,
HasSelector: hasSelector,
}, nil
}
@@ -213,6 +253,34 @@ func (r *Remote) Fetch(path string, cacheDirOpt ...string) (string, error) {
return "", fmt.Errorf("remote sources are disabled due to 'HELMFILE_DISABLE_INSECURE_FEATURES'")
}
// A wildcard in the "@<file>" selector is expanded by the caller (e.g.
// Storage.resolveFile) against the directory downloaded below. Reject the
// shapes where that can never work, instead of letting it fail later with a
// confusing "file not found" naming a path that contains a literal "*".
//
// Only "*" is treated as an unambiguous wildcard signal here: "?" can never
// reach u.File (url.Parse splits the query string at the first "?"), and
// "[" legitimately appears in existing literal file names, so neither
// should make Fetch reject an otherwise-valid single-file reference.
if strings.Contains(u.File, "*") {
switch {
case u.Getter == "normal":
// Plain http(s):// and s3:// URLs (ParseNormal) always download a
// single object.
return "", fmt.Errorf("wildcards are not supported for %s:// sources, which fetch a single file: %s (use a directory-capable getter such as git::)", u.Scheme, path)
case u.Getter == "s3" && decompressorForFile(filepath.Base(u.Dir)) == nil:
// Forced s3:: also fetches a single object, unless it's an archive:
// S3Getter decompresses those into the cache dir, so a selector can
// still match a file inside.
return "", fmt.Errorf("wildcards are not supported for s3:: sources unless the object is an archive: %s", path)
case !u.HasSelector:
// Without an explicit "@" selector, Dir/File are inferred from the
// last path segment (see Parse), which can't tell a repository root
// from a wildcard file name.
return "", fmt.Errorf(`wildcards require an explicit "@" selector marking the repository root, e.g. git::https://host/org/repo.git@dir/*.yaml?ref=main: %s`, path)
}
}
srcDir := fmt.Sprintf("%s://%s/%s", u.Scheme, u.Host, u.Dir)
file := u.File
+247 -15
View File
@@ -760,16 +760,65 @@ func TestIsRemote(t *testing.T) {
}
}
func TestHasGlobPattern(t *testing.T) {
testcases := []struct {
name string
input string
expected bool
}{
{
name: "wildcard file selector",
input: "git::https://github.com/org/repo.git@path/to/dir/*.yaml?ref=main",
expected: true,
},
{
name: "literal file selector with ref query param",
input: "git::https://github.com/org/repo.git@path/to/values.yaml?ref=main",
expected: false,
},
{
name: "placeholder bracket in dir, not file",
input: `git::https://github.com/[$GITHUB_ORG]/repository-name.git@/values.dev.yaml?ref=main`,
expected: false,
},
{
name: "ipv6 host, not file",
input: "git::https://[::1]/org/repo.git@values.yaml?ref=main",
expected: false,
},
{
name: "character class in file selector",
input: "git::https://github.com/org/repo.git@dir/values-[abc].yaml?ref=main",
expected: true,
},
{
name: "relative local path",
input: "relative/path/*.yaml",
expected: false,
},
}
for _, tt := range testcases {
t.Run(tt.name, func(t *testing.T) {
result := HasGlobPattern(tt.input)
if result != tt.expected {
t.Errorf("HasGlobPattern(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
func TestParse(t *testing.T) {
testcases := []struct {
name string
input string
getter string
scheme string
dir string
file string
query string
err string
name string
input string
getter string
scheme string
dir string
file string
query string
hasSelector bool
err string
}{
{
name: "miss scheme",
@@ -782,13 +831,24 @@ func TestParse(t *testing.T) {
err: "parse url: local absolute path is not a remote URL: /absolute/path/to/file.yaml",
},
{
name: "git scheme",
input: "git::https://github.com/stakater/Forecastle.git@deployments/kubernetes/chart/forecastle?ref=v1.0.54",
getter: "git",
scheme: "https",
dir: "/stakater/Forecastle.git",
file: "deployments/kubernetes/chart/forecastle",
query: "ref=v1.0.54",
name: "git scheme",
input: "git::https://github.com/stakater/Forecastle.git@deployments/kubernetes/chart/forecastle?ref=v1.0.54",
getter: "git",
scheme: "https",
dir: "/stakater/Forecastle.git",
file: "deployments/kubernetes/chart/forecastle",
query: "ref=v1.0.54",
hasSelector: true,
},
{
name: "git scheme with wildcard file selector",
input: "git::https://github.com/org/repo.git@path/to/dir/*.yaml?ref=main",
getter: "git",
scheme: "https",
dir: "/org/repo.git",
file: "path/to/dir/*.yaml",
query: "ref=main",
hasSelector: true,
},
{
name: "s3 scheme",
@@ -878,12 +938,14 @@ func TestParse(t *testing.T) {
}
var getter, scheme, dir, file, query string
var hasSelector bool
if src != nil {
getter = src.Getter
scheme = src.Scheme
dir = src.Dir
file = src.File
query = src.RawQuery
hasSelector = src.HasSelector
}
if diff := cmp.Diff(tt.getter, getter); diff != "" {
@@ -905,6 +967,10 @@ func TestParse(t *testing.T) {
if diff := cmp.Diff(tt.query, query); diff != "" {
t.Fatalf("Unexpected query:\n%s", diff)
}
if diff := cmp.Diff(tt.hasSelector, hasSelector); diff != "" {
t.Fatalf("Unexpected hasSelector:\n%s", diff)
}
})
}
}
@@ -987,6 +1053,172 @@ func TestRemote_Fetch(t *testing.T) {
}
}
func TestRemote_Fetch_Wildcard(t *testing.T) {
cleanfs := map[string]string{
CacheDir(): "",
}
cachefs := map[string]string{
filepath.Join(CacheDir(), "https_github_com_helmfile_helmfile_git.ref=v0.151.0/examples/values/a.yaml"): "foo: bar",
}
testcases := []struct {
name string
files map[string]string
expectCacheHit bool
}{
{name: "not expectCacheHit", files: cleanfs, expectCacheHit: false},
{name: "expectCacheHit", files: cachefs, expectCacheHit: true},
}
for _, tt := range testcases {
t.Run(tt.name, func(t *testing.T) {
testfs := testhelper.NewTestFs(tt.files)
hit := true
get := func(wd, src, dst string) error {
if wd != CacheDir() {
return fmt.Errorf("unexpected wd: %s", wd)
}
// The wildcard selector is stripped from the getter source: the
// whole directory is downloaded and the pattern is applied
// against it afterwards.
if src != "git::https://github.com/helmfile/helmfile.git?ref=v0.151.0" {
return fmt.Errorf("unexpected src: %s", src)
}
hit = false
return nil
}
getter := &testGetter{
get: get,
}
remote := &Remote{
Logger: helmexec.NewLogger(io.Discard, "debug"),
Home: CacheDir(),
Getter: getter,
fs: testfs.ToFileSystem(),
}
url := "git::https://github.com/helmfile/helmfile.git@examples/values/*.yaml?ref=v0.151.0"
file, err := remote.Fetch(url)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// The wildcard selector is joined onto the cache dir untouched: it is
// the caller's (Storage.resolveFile's) job to glob it.
expectedFile := filepath.Join(CacheDir(), "https_github_com_helmfile_helmfile_git.ref=v0.151.0/examples/values/*.yaml")
if file != expectedFile {
t.Errorf("unexpected file located: %s vs expected: %s", file, expectedFile)
}
// A wildcard selector must share the cache entry with a literal
// reference to the same repo/ref: the cache key never depends on File.
if tt.expectCacheHit && !hit {
t.Errorf("unexpected result: unexpected cache miss")
}
if !tt.expectCacheHit && hit {
t.Errorf("unexpected result: unexpected cache hit")
}
})
}
}
func TestRemote_Fetch_WildcardUnsupported(t *testing.T) {
testcases := []struct {
name string
url string
errContains string
}{
{
name: "plain https single file",
url: "https://example.com/dir/*.yaml",
errContains: "wildcards are not supported for https:// sources",
},
{
name: "plain s3 single file",
url: "s3://bucket/dir/*.yaml",
errContains: "wildcards are not supported for s3:// sources",
},
{
name: "forced s3 getter, non-archive object",
url: "s3::https://bucket.s3.us-east-2.amazonaws.com/dir/*.yaml",
errContains: "wildcards are not supported for s3:: sources",
},
{
name: "no explicit selector",
url: "git::https://git.company.org/helmfiles/global/*.yaml?ref=master",
errContains: `wildcards require an explicit "@" selector`,
},
}
for _, tt := range testcases {
t.Run(tt.name, func(t *testing.T) {
called := false
get := func(wd, src, dst string) error {
called = true
return nil
}
getter := &testGetter{get: get}
testfs := testhelper.NewTestFs(map[string]string{CacheDir(): ""})
remote := &Remote{
Logger: helmexec.NewLogger(io.Discard, "debug"),
Home: CacheDir(),
Getter: getter,
S3Getter: getter,
HttpGetter: getter,
fs: testfs.ToFileSystem(),
}
_, err := remote.Fetch(tt.url)
if err == nil {
t.Fatalf("expected an error, got none")
}
if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errContains)
}
if called {
t.Errorf("getter should not have been invoked")
}
})
}
}
// TestRemote_Fetch_WildcardAllowedS3Archive locks in the one s3:: shape a
// wildcard selector IS allowed for: a forced s3:: getter pointing at an
// archive. S3Getter decompresses archives into the cache dir (see
// decompressorForFile), so a selector can still match files inside it, unlike
// a single non-archive s3:: object (covered by TestRemote_Fetch_WildcardUnsupported).
func TestRemote_Fetch_WildcardAllowedS3Archive(t *testing.T) {
testfs := testhelper.NewTestFs(map[string]string{CacheDir(): ""})
called := false
get := func(wd, src, dst string) error {
called = true
return nil
}
getter := &testGetter{get: get}
remote := &Remote{
Logger: helmexec.NewLogger(io.Discard, "debug"),
Home: CacheDir(),
S3Getter: getter,
fs: testfs.ToFileSystem(),
}
url := "s3::https://bucket.s3.us-east-2.amazonaws.com/dir/app.tar.gz@*.yaml"
if _, err := remote.Fetch(url); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Errorf("expected S3Getter to be invoked for an archive wildcard selector")
}
}
// TestAWSSDKLogLevelInit verifies that the init() function reads HELMFILE_AWS_SDK_LOG_LEVEL correctly
func TestAWSSDKLogLevelInit(t *testing.T) {
tests := []struct {
+1 -1
View File
@@ -549,7 +549,7 @@ dependencies:
errCh := make(chan error, numGoroutines)
ready := make(chan struct{})
for i := 0; i < numGoroutines; i++ {
for range numGoroutines {
wg.Add(1)
readyWg.Add(1)
go func() {
+6 -5
View File
@@ -260,8 +260,8 @@ func chartDependenciesAlias(namespace, releaseName string) string {
// the chart path when helm processes OCI references during dependency update.
// See issue #954.
func ociDependencyChartName(chart string) string {
if idx := strings.LastIndex(chart, "/"); idx >= 0 {
return chart[idx+1:]
if _, name, found := strings.CutLast(chart, "/"); found {
return name
}
return chart
}
@@ -273,10 +273,11 @@ func ociDependencyChartName(chart string) string {
// chart="path_with_underscores/example", baseURL="oci://registry.example.com"
// → "oci://registry.example.com/path_with_underscores"
func ociDependencyRepoURL(chart, ociBaseURL string) string {
if idx := strings.LastIndex(chart, "/"); idx >= 0 {
return strings.TrimSuffix(ociBaseURL, "/") + "/" + chart[:idx]
pathPrefix, _, found := strings.CutLast(chart, "/")
if !found {
return ociBaseURL
}
return ociBaseURL
return strings.TrimSuffix(ociBaseURL, "/") + "/" + pathPrefix
}
func getUnresolvedDependenciess(st *HelmState) (string, *UnresolvedDependencies) {

Some files were not shown because too many files have changed in this diff Show More