Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ fluid diagnose my-dataset -n default --archive
|---------|-------------|
| `fluid inspect` | List Pods, Runtimes, PVCs, and related resources for a Dataset |
| `fluid diagnose` | Collect a support bundle (YAML, logs, events) for a Dataset |
| `fluid diagnose config` | Manage AI/LLM settings for diagnose (`~/.fluid/config`) |
| `fluid version` | Print CLI version |

For flags and examples, use `--help` on any command:
Expand All @@ -31,6 +32,21 @@ fluid inspect --help
fluid diagnose --help
```

## AI-assisted diagnosis

`fluid diagnose` can call an OpenAI-compatible LLM API to analyze collected cluster context, or export prompt files for manual copy/paste.

```bash
fluid diagnose config set llm-endpoint https://api.openai.com/v1
export FLUID_LLM_API_KEY=sk-...

fluid diagnose my-dataset -n default -o dir
```

Artifact directory includes `context.json`, `prompt.txt`, and `llm-analysis.txt` when LLM analysis runs. Use `--llm-skip` to collect prompts only without calling the API.

See [Diagnose guide](docs/guides/diagnose.md) for details.

## Documentation

- [Install](docs/install.md)
Expand Down
45 changes: 39 additions & 6 deletions cmd/fluid/root/diagnose/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ type Options struct {
includeControllerLogs bool
since string

// LLM/AI output (Phase 3 stub)
promptFile string
promptFile string
llmEndpoint string
llmModel string
llmSkip bool
}

func NewDiagnoseCommand(configFlags *genericclioptions.ConfigFlags) *cobra.Command {
Expand Down Expand Up @@ -79,7 +81,14 @@ into a tar.gz archive.`,
fluid diagnose my-dataset -n default --output-dir /tmp/diag --no-logs

# Only collect events from the last hour
fluid diagnose my-dataset -n default --since 1h`,
fluid diagnose my-dataset -n default --since 1h

# Configure LLM settings (OpenAI-compatible API)
fluid diagnose config set llm-endpoint https://api.openai.com/v1
export FLUID_LLM_API_KEY=sk-...

# Collect artifacts and request LLM analysis
fluid diagnose my-dataset -n default -o dir`,
Args: cobra.ExactArgs(1),
SilenceUsage: true,
SilenceErrors: true,
Expand Down Expand Up @@ -111,9 +120,13 @@ into a tar.gz archive.`,
cmd.Flags().BoolVar(&o.includeControllerLogs, "include-controller-logs", false, "Also collect Fluid controller logs from fluid-system namespace")
cmd.Flags().StringVar(&o.since, "since", "", "Only collect logs/events newer than this duration (e.g. 1h, 30m)")

// Phase 3 stub
cmd.Flags().StringVar(&o.promptFile, "prompt-file", "", "[Phase 3] Write prompt-ready diagnostic context to this file")
_ = cmd.Flags().MarkHidden("prompt-file")
// AI-assisted diagnosis
cmd.Flags().StringVar(&o.promptFile, "prompt-file", "", "Also write prompt-ready diagnostic text to this file")
cmd.Flags().StringVar(&o.llmEndpoint, "llm-endpoint", "", "LLM API base URL (overrides FLUID_LLM_ENDPOINT and ~/.fluid/config)")
cmd.Flags().StringVar(&o.llmModel, "llm-model", "", "LLM model name (overrides FLUID_LLM_MODEL and ~/.fluid/config)")
cmd.Flags().BoolVar(&o.llmSkip, "llm-skip", false, "Skip LLM analysis (when endpoint is configured, analysis runs by default)")

cmd.AddCommand(newConfigCommand())

return cmd
}
Expand All @@ -138,6 +151,11 @@ func (o *Options) run(cmd *cobra.Command) error {
return fmt.Errorf("invalid output mode %q, expected tui|dir|stdout", o.output)
}

llmSettings, err := diagpkg.ResolveLLMSettings(o.llmEndpoint, o.llmModel, o.llmSkip, cmd.Flags().Changed("llm-skip"))
if err != nil {
return err
}

runOpts := diagpkg.Options{
DatasetName: o.datasetName,
Namespace: o.namespace,
Expand All @@ -147,6 +165,12 @@ func (o *Options) run(cmd *cobra.Command) error {
NoLogs: o.noLogs,
IncludeControllerLogs: o.includeControllerLogs,
Since: o.since,
PromptFile: o.promptFile,
LLMEndpoint: llmSettings.Endpoint,
LLMAPIKey: llmSettings.APIKey,
LLMModel: llmSettings.Model,
LLMSkip: llmSettings.Skip,
Stderr: cmd.ErrOrStderr(),
}
if o.output == "tui" {
runOpts.Output = "dir"
Expand Down Expand Up @@ -184,6 +208,15 @@ func (o *Options) run(cmd *cobra.Command) error {
}

fmt.Fprintf(cmd.OutOrStdout(), "diagnose: collected artifacts at %s\n", result.OutputPath)
if result.ContextPath != "" {
fmt.Fprintf(cmd.OutOrStdout(), "diagnose: diagnostic context written to %s\n", result.ContextPath)
}
if result.PromptPath != "" {
fmt.Fprintf(cmd.OutOrStdout(), "diagnose: prompt written to %s\n", result.PromptPath)
}
if result.LLMAnalysisPath != "" {
fmt.Fprintf(cmd.OutOrStdout(), "diagnose: LLM analysis written to %s\n", result.LLMAnalysisPath)
}
if result.ArchivePath != "" {
fmt.Fprintf(cmd.OutOrStdout(), "diagnose: archive written to %s\n", result.ArchivePath)
}
Expand Down
219 changes: 219 additions & 0 deletions cmd/fluid/root/diagnose/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Copyright 2026 The Fluid Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package diagnose

import (
"fmt"
"os"

diagpkg "github.com/fluid-cloudnative/fluid-cli/pkg/diagnose"
"github.com/spf13/cobra"
"sigs.k8s.io/yaml"
)

func newConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage diagnose AI/LLM settings",
Long: `Manage Fluid diagnose AI settings stored in ~/.fluid/config.

Settings are used for OpenAI-compatible LLM analysis during fluid diagnose.
Prefer FLUID_LLM_API_KEY for secrets instead of storing apiKey in the config file.`,
}

cmd.AddCommand(
newConfigSetCommand(),
newConfigGetCommand(),
newConfigUnsetCommand(),
newConfigViewCommand(),
)

return cmd
}

func newConfigSetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set",
Short: "Set a diagnose LLM configuration value",
}
cmd.AddCommand(
&cobra.Command{
Use: "llm-endpoint <url>",
Short: "Set the default LLM API base URL",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := diagpkg.SetLLMEndpoint(args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "diagnose config: saved llm endpoint\n")
return nil
},
},
&cobra.Command{
Use: "llm-api-key <key>",
Short: "Set the LLM API key in ~/.fluid/config (prefer FLUID_LLM_API_KEY env)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := diagpkg.SetLLMAPIKey(args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "diagnose config: saved llm api key\n")
return nil
},
},
&cobra.Command{
Use: "llm-model <model>",
Short: "Set the default LLM model name",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := diagpkg.SetLLMModel(args[0]); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "diagnose config: saved llm model\n")
return nil
},
},
)
return cmd
}

func newConfigGetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: "Print a diagnose LLM configuration value",
}
cmd.AddCommand(
&cobra.Command{
Use: "llm-endpoint",
Short: "Print the configured LLM API endpoint",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
endpoint, err := diagpkg.GetLLMEndpoint()
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), endpoint)
return nil
},
},
&cobra.Command{
Use: "llm-api-key",
Short: "Print whether an LLM API key is configured",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ok, err := diagpkg.GetLLMAPIKeyConfigured()
if err != nil {
return err
}
if ok {
fmt.Fprintln(cmd.OutOrStdout(), "configured")
}
return nil
},
},
&cobra.Command{
Use: "llm-model",
Short: "Print the configured LLM model",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
model, err := diagpkg.GetLLMModel()
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), model)
return nil
},
},
)
return cmd
}

func newConfigUnsetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "unset",
Short: "Remove a diagnose LLM configuration value",
}
cmd.AddCommand(
&cobra.Command{
Use: "llm-endpoint",
Short: "Remove the configured LLM API endpoint",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := diagpkg.UnsetLLMEndpoint(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "diagnose config: removed llm endpoint\n")
return nil
},
},
&cobra.Command{
Use: "llm-api-key",
Short: "Remove the LLM API key from ~/.fluid/config",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := diagpkg.UnsetLLMAPIKey(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "diagnose config: removed llm api key\n")
return nil
},
},
&cobra.Command{
Use: "llm-model",
Short: "Remove the configured LLM model",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if err := diagpkg.UnsetLLMModel(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "diagnose config: removed llm model\n")
return nil
},
},
)
return cmd
}

func newConfigViewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "view",
Short: "Show the diagnose section of ~/.fluid/config",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := diagpkg.LoadUserConfig()
if err != nil {
return err
}
if cfg.Diagnose.LLM.APIKey != "" {
cfg.Diagnose.LLM.APIKey = "<set>"
}
path, err := diagpkg.ConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Fprintf(cmd.OutOrStdout(), "# %s (not created yet)\n", path)
return nil
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "# %s\n%s", path, string(data))
return nil
},
}
return cmd
}
50 changes: 50 additions & 0 deletions cmd/fluid/root/diagnose/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package diagnose

import (
"bytes"
"os"
"testing"

diagpkg "github.com/fluid-cloudnative/fluid-cli/pkg/diagnose"
)

func TestDiagnoseConfig_SetAndGet(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)

root := bytes.NewBuffer(nil)
setCmd := newConfigSetCommand()
setCmd.SetOut(root)
setCmd.SetErr(root)
setCmd.SetArgs([]string{"llm-endpoint", "https://api.example.com/v1"})
if err := setCmd.Execute(); err != nil {
t.Fatalf("config set: %v", err)
}

got, err := diagpkg.GetLLMEndpoint()
if err != nil {
t.Fatalf("GetLLMEndpoint: %v", err)
}
if got != "https://api.example.com/v1" {
t.Fatalf("got %q", got)
}

out := &bytes.Buffer{}
getCmd := newConfigGetCommand()
getCmd.SetOut(out)
getCmd.SetArgs([]string{"llm-endpoint"})
if err := getCmd.Execute(); err != nil {
t.Fatalf("config get: %v", err)
}
if out.String() != "https://api.example.com/v1\n" {
t.Fatalf("get output: %q", out.String())
}

path, err := diagpkg.ConfigPath()
if err != nil {
t.Fatalf("ConfigPath: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("config file missing: %v", err)
}
}
Loading
Loading