mirror of
https://github.com/helmfile/helmfile.git
synced 2026-09-30 07:20:52 +02:00
* feat: add `helmfile doctor` command for AI-assisted diff analysis `helmfile doctor` runs `helmfile diff` and asks an OpenAI-compatible LLM to summarize the changes and flag risks (data loss, security exposure, breaking changes, downtime, performance, best-practice issues). Key design decisions: - When no LLM is configured, doctor is equivalent to `helmfile diff` with one exception: --show-secrets is always forced off (secrets never reach stdout, even without an LLM). - Secrets are ALWAYS redacted via two layers: (1) ShowSecrets() forced to false so helm-diff emits <REDACTED> placeholders; (2) a defense-in-depth text redactor strips residual secret-looking content (Secret YAML blocks, sensitive key/value lines, base64 blobs, JWT tokens) before LLM transmission. - LLM configuration precedence: env (HELMFILE_LLM_*) < helmfile.yaml (llm:) < CLI flags (--llm-*). - Supports any OpenAI-compatible backend (OpenAI, Azure, One-API, LiteLLM, Ollama, etc.) with automatic response_format fallback for backends that don't support JSON mode. - Prompt injection defense: release names and environment values are JSON-encoded before insertion into the LLM prompt. - Exit codes: 0 (success/low-risk), 2 (high-risk gate, bypass with --force), 1 (other errors). Helm-diff's 'detected changes' exit-2 is swallowed. New packages: - pkg/agent/llm: OpenAI-compatible client with JSON response parsing, mock client for testing, prompt builder with injection defense. - pkg/agent/doctor: secret redactor (state machine + regex), report renderer (markdown + JSON), config resolver (env < yaml < flag merge). Testing: 70+ unit tests covering redaction patterns, prompt injection, response_format fallback, JSON parsing, yaml roundtrip, concurrency safety, panic recovery, and error propagation. go test -race passes. Documentation: full doctor section in docs/cli.md, llm: block reference in docs/configuration.md, updated skills/helmfile for AI agents. Signed-off-by: yxxhero <aiopsclub@163.com> * docs: fix doctor equivalence wording per PR review Per review feedback (PR #2660): the docs claimed doctor is 'equivalent to helmfile diff — same flags, same output, same exit codes' in the unconfigured path, but this over-promises because: 1. doctor --output is the report format (not helm-diff's output format) 2. helm-diff's --output is exposed as --diff-output in doctor 3. --show-secrets is silently ignored Updated all three locations (cli.md, cmd/doctor.go Long + godoc, pkg/app/doctor.go godoc) to say 'falls back to helmfile diff with --show-secrets forced off' and explicitly note the --output / --diff-output flag difference. Signed-off-by: yxxhero <aiopsclub@163.com> --------- Signed-off-by: yxxhero <aiopsclub@163.com>
143 lines
3.9 KiB
Go
143 lines
3.9 KiB
Go
package state
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/helmfile/helmfile/pkg/agent/llm"
|
|
yaml "github.com/helmfile/helmfile/pkg/yaml"
|
|
)
|
|
|
|
// TestReleaseSetSpec_LLMYamlRoundtrip is a regression guard for the
|
|
// ReleaseSetSpec.LLM yaml tag. Without this test, a typo in the tag (e.g.
|
|
// `yaml:"LLM"` or `yaml:"ai"`) would silently disable every helmfile.yaml
|
|
// `llm:` block in production. The tag lives at pkg/state/state.go:ReleaseSetSpec.
|
|
//
|
|
// Cases covered:
|
|
//
|
|
// - Full llm: block unmarshals correctly into ReleaseSetSpec.LLM
|
|
// - HelmState.UnmarshalYAML (the alias-based path used by the real loader)
|
|
// also picks up the LLM field — guards against the alias stripping it.
|
|
// - Missing llm: block leaves ReleaseSetSpec.LLM as zero-value Config.
|
|
// - `llm: {}` (explicit empty) is equivalent to missing.
|
|
// - Partial llm: block (only APIKey + Model) sets only those fields.
|
|
//
|
|
// We do NOT test template rendering ({{ env "KEY" }}) here — that is helmfile
|
|
// core two-pass renderer behavior, exercised in pkg/app tests.
|
|
func TestReleaseSetSpec_LLMYamlRoundtrip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
yaml string
|
|
want llm.Config
|
|
wantSet bool // whether want is fully populated (vs zero)
|
|
}{
|
|
{
|
|
name: "full llm block",
|
|
yaml: `
|
|
releases:
|
|
- name: myapp
|
|
chart: mychart
|
|
llm:
|
|
baseURL: https://one-api.example/v1
|
|
apiKey: sk-test-123
|
|
model: gpt-4o
|
|
timeout: 90s
|
|
maxTokens: 8192
|
|
temperature: 0.5
|
|
`,
|
|
want: llm.Config{
|
|
BaseURL: "https://one-api.example/v1",
|
|
APIKey: "sk-test-123",
|
|
Model: "gpt-4o",
|
|
Timeout: 90 * time.Second,
|
|
MaxTokens: 8192,
|
|
Temperature: 0.5,
|
|
},
|
|
wantSet: true,
|
|
},
|
|
{
|
|
name: "minimal llm block (OpenAI official user, no baseURL)",
|
|
yaml: `
|
|
llm:
|
|
apiKey: sk-...
|
|
model: gpt-4o
|
|
`,
|
|
want: llm.Config{
|
|
APIKey: "sk-...",
|
|
Model: "gpt-4o",
|
|
},
|
|
wantSet: true,
|
|
},
|
|
{
|
|
name: "missing llm block",
|
|
yaml: `
|
|
releases:
|
|
- name: myapp
|
|
chart: mychart
|
|
`,
|
|
want: llm.Config{},
|
|
wantSet: false,
|
|
},
|
|
{
|
|
name: "empty llm block",
|
|
yaml: `
|
|
releases:
|
|
- name: myapp
|
|
chart: mychart
|
|
llm: {}
|
|
`,
|
|
want: llm.Config{},
|
|
wantSet: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Use HelmState directly — this exercises the alias-based
|
|
// UnmarshalYAML the production loader goes through.
|
|
var st HelmState
|
|
if err := yaml.Unmarshal([]byte(tt.yaml), &st); err != nil {
|
|
t.Fatalf("Unmarshal failed: %v", err)
|
|
}
|
|
got := st.LLM
|
|
if got != tt.want {
|
|
t.Errorf("LLM mismatch:\n got = %+v\n want = %+v", got, tt.want)
|
|
}
|
|
// ReleaseSpec sanity: when the yaml contained releases, they must
|
|
// unmarshal independently of the llm: block presence (the two
|
|
// fields must not interfere with each other).
|
|
if strings.Contains(tt.yaml, "releases:") && len(st.Releases) == 0 {
|
|
t.Errorf("Releases empty despite yaml containing a releases: block")
|
|
}
|
|
// IsConfigured must agree with wantSet.
|
|
if got.IsConfigured() != tt.wantSet {
|
|
t.Errorf("IsConfigured = %v, want %v", got.IsConfigured(), tt.wantSet)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestReleaseSetSpec_LLMFieldDoesNotLeakIntoSubhelmfile verifies that the
|
|
// llm: block lives on ReleaseSetSpec (not on HelmState unexported fields),
|
|
// so the field is reachable from any code holding a *HelmState pointer via
|
|
// the embedded spec.
|
|
func TestReleaseSetSpec_LLMFieldDoesNotLeakIntoSubhelmfile(t *testing.T) {
|
|
const src = `
|
|
llm:
|
|
apiKey: k
|
|
model: m
|
|
`
|
|
var st HelmState
|
|
if err := yaml.Unmarshal([]byte(src), &st); err != nil {
|
|
t.Fatalf("Unmarshal failed: %v", err)
|
|
}
|
|
// Reach the field through every name by which downstream code might see it.
|
|
if !st.LLM.IsConfigured() {
|
|
t.Errorf("HelmState.LLM not configured; field not promoted from ReleaseSetSpec?")
|
|
}
|
|
if !st.ReleaseSetSpec.LLM.IsConfigured() {
|
|
t.Errorf("ReleaseSetSpec.LLM not configured; yaml tag not pointing at the right field?")
|
|
}
|
|
}
|