Files
helmfile/pkg/event/bus.go
T
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

234 lines
6.2 KiB
Go

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"
)
type Hook struct {
Name string `yaml:"name"`
Events []string `yaml:"events"`
Command string `yaml:"command"`
Kubectl map[string]string `yaml:"kubectlApply,omitempty"`
Args []string `yaml:"args"`
ShowLogs bool `yaml:"showlogs"`
}
type event struct {
Name string
Error error
}
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
Chart string
Env environment.Environment
Fs *filesystem.FileSystem
Logger *zap.SugaredLogger
}
var (
disableHooks bool
)
func init() {
disableHooks, _ = strconv.ParseBool(os.Getenv(envvar.DisableHooks))
}
func (bus *Bus) Trigger(evt string, evtErr error, context map[string]any) (bool, error) {
if disableHooks && len(bus.Hooks) > 0 {
return false, fmt.Errorf("%s is active, hooks are disabled", envvar.DisableHooks)
}
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,
Ctx: ctx,
}
}
executed := false
for _, hook := range bus.Hooks {
contained := false
for _, e := range hook.Events {
contained = contained || e == evt
}
if !contained {
continue
}
hookExecuted, err := bus.runHook(hook, evt, evtErr, context)
if err != nil {
return false, err
}
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
}
}