Skip to content
Merged
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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
Expand All @@ -132,7 +133,7 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/moby/spdystream v0.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
Expand Down Expand Up @@ -311,6 +313,8 @@ github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3v
github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
Expand Down
2 changes: 2 additions & 0 deletions internal/analysisengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ type ClusterInfo struct {
Region string
CloudProvider string
Version string
Type string // e.g. "rosa", "osd", "aro"
Hypershift bool
}

// Config holds configuration for the analysis engine.
Expand Down
6 changes: 6 additions & 0 deletions internal/prompts/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ func (ps *PromptStore) loadTemplates(filesystem fs.FS) error {
})
}

// RegisterTemplates loads additional templates from the given filesystem,
// overwriting any existing templates with the same ID.
func (ps *PromptStore) RegisterTemplates(templatesFS fs.FS) error {
return ps.loadTemplates(templatesFS)
}

func (ps *PromptStore) GetTemplate(id string) (*PromptTemplate, error) {
template, exists := ps.templates[id]
if !exists {
Expand Down
28 changes: 28 additions & 0 deletions internal/prompts/prompts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package prompts

import (
"testing"
"testing/fstest"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -12,6 +13,33 @@ func TestNewPromptStore(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, store)
assert.Greater(t, len(store.templates), 0, "Should have loaded some templates")

_, err = store.GetTemplate("default")
assert.NoError(t, err, "default template should be loaded")
}

func TestRegisterTemplates_OverwritesExisting(t *testing.T) {
store, err := NewPromptStore(DefaultTemplates())
require.NoError(t, err)

original, err := store.GetTemplate("default")
require.NoError(t, err)
require.NotNil(t, original)

replacementYAML := `system_prompt: "replacement system prompt"
user_prompt: "replacement user prompt"
`
overrideFS := fstest.MapFS{
"default.yaml": &fstest.MapFile{Data: []byte(replacementYAML)},
}

err = store.RegisterTemplates(overrideFS)
require.NoError(t, err)

updated, err := store.GetTemplate("default")
require.NoError(t, err)
assert.Equal(t, "replacement system prompt", updated.SystemPrompt)
assert.Equal(t, "replacement user prompt", updated.UserPrompt)
}

func TestGetTemplate(t *testing.T) {
Expand Down
25 changes: 25 additions & 0 deletions pkg/krknai/aggregator/aggregator.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ import (
"gopkg.in/yaml.v3"
)

// ClusterInfo holds cluster metadata for krkn-ai analysis.
type ClusterInfo struct {
ID string `json:"id,omitempty" yaml:"id,omitempty"`
Version string `json:"version,omitempty" yaml:"version,omitempty"`
Type string `json:"type,omitempty" yaml:"type,omitempty"` // Combined: "cloud/platform[-hcp]", e.g. "aws/rosa-hcp"
Region string `json:"region,omitempty" yaml:"region,omitempty"`
Environment string `json:"environment,omitempty" yaml:"environment,omitempty"` // e.g. "stage", "production", "integration"
}

const (
// Default file paths relative to results directory
allCSVPath = "reports/all.csv"
Expand All @@ -29,6 +38,7 @@ const (
type KrknAIAggregator struct {
logger logr.Logger
topScenariosCount int
clusterInfo *ClusterInfo
}

// KrknAIData holds aggregated krkn-ai results with minimal context.
Expand All @@ -39,6 +49,7 @@ type KrknAIData struct {
HealthCheckReport []HealthCheckResult `json:"healthCheckReport"`
LogArtifacts []internalAggregator.LogEntry `json:"logArtifacts"`
ConfigSummary string `json:"configSummary,omitempty"`
ClusterInfo *ClusterInfo `json:"clusterInfo,omitempty"`
}

// KrknAISummary provides high-level statistics about the chaos test run.
Expand Down Expand Up @@ -89,6 +100,16 @@ func (a *KrknAIAggregator) WithTopScenariosCount(count int) *KrknAIAggregator {
return a
}

// WithClusterInfo sets cluster metadata to include in collected data.
// A defensive copy is stored so later mutations by the caller don't affect stored data.
func (a *KrknAIAggregator) WithClusterInfo(info *ClusterInfo) *KrknAIAggregator {
if info != nil {
cp := *info
a.clusterInfo = &cp
}
return a
}

// Collect gathers krkn-ai results from the specified directory.
func (a *KrknAIAggregator) Collect(ctx context.Context, resultsDir string) (*KrknAIData, error) {
a.logger.Info("collecting krkn-ai results", "resultsDir", resultsDir)
Expand All @@ -98,6 +119,10 @@ func (a *KrknAIAggregator) Collect(ctx context.Context, resultsDir string) (*Krk
}

data := &KrknAIData{}
if a.clusterInfo != nil {
cp := *a.clusterInfo
data.ClusterInfo = &cp
}
var collectionErrors []string

// Collect scenario results from all.csv
Expand Down
41 changes: 41 additions & 0 deletions pkg/krknai/aggregator/aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,44 @@ scenario:

require.NoError(t, os.WriteFile(filepath.Join(resultsDir, "krkn-ai.yaml"), []byte(configYAML), 0o644))
}

func TestWithClusterInfo_DefensiveCopy(t *testing.T) {
info := &ClusterInfo{
ID: "original-id",
Version: "4.14.0",
Type: "aws/rosa-hcp",
Region: "us-east-1",
Environment: "stage",
}

agg := NewKrknAIAggregator(context.Background())
agg.WithClusterInfo(info)

// Mutate the caller's struct after passing it in
info.ID = "mutated-id"
info.Region = "eu-west-1"

assert.Equal(t, "original-id", agg.clusterInfo.ID, "stored copy must be isolated from caller mutation")
assert.Equal(t, "us-east-1", agg.clusterInfo.Region, "stored copy must be isolated from caller mutation")
}

func TestCollect_ClusterInfoIsolation(t *testing.T) {
tempDir := t.TempDir()
resultsDir := filepath.Join(tempDir, "results")
reportsDir := filepath.Join(resultsDir, "reports")
require.NoError(t, os.MkdirAll(reportsDir, 0o755))
createKrknAITestFiles(t, resultsDir, reportsDir)

info := &ClusterInfo{ID: "test-cluster", Version: "4.14.0"}
agg := NewKrknAIAggregator(context.Background())
agg.WithClusterInfo(info)

data, err := agg.Collect(context.Background(), resultsDir)
require.NoError(t, err)
require.NotNil(t, data.ClusterInfo)

// The output ClusterInfo should be a separate copy from the aggregator's internal one
assert.Equal(t, "test-cluster", data.ClusterInfo.ID)
data.ClusterInfo.ID = "mutated-output"
assert.Equal(t, "test-cluster", agg.clusterInfo.ID, "aggregator's stored copy must not be affected by output mutation")
}
73 changes: 63 additions & 10 deletions pkg/krknai/analysisengine/engine.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
package analysisengine

import (
"bytes"
"context"
"embed"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"time"

"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"github.com/microcosm-cc/bluemonday"
"github.com/openshift/osde2e/internal/analysisengine"
"github.com/openshift/osde2e/internal/llm"
"github.com/openshift/osde2e/internal/llm/tools"
Expand All @@ -18,21 +24,22 @@ import (
"gopkg.in/yaml.v3"
)

//go:embed prompts/krknai.yaml
var krknaiTemplatesFS embed.FS
//go:embed prompts/*
var krknPrompts embed.FS

const (
analysisDirName = "llm-analysis"
summaryFileName = "summary.yaml"

// krknAIPromptTemplate is the prompt template ID for krkn-ai analysis.
krknAIPromptTemplate = "krknai"
htmlTemplatePath = "prompts/report.html"
)

// Config holds configuration for the krkn-ai analysis engine.
type Config struct {
analysisengine.BaseConfig
TopScenariosCount int // Number of top scenarios to include (default: 10)
TopScenariosCount int // Number of top scenarios to include (default: 10)
ReportFormat string // "json" (default), "markdown", or "html"
}

// Engine analyzes krkn-ai chaos test results using LLM.
Expand Down Expand Up @@ -60,14 +67,17 @@ func New(ctx context.Context, config *Config) (*Engine, error) {
agg.WithTopScenariosCount(config.TopScenariosCount)
}

templatesFS, err := fs.Sub(krknaiTemplatesFS, "prompts")
promptStore, err := prompts.NewPromptStore(prompts.DefaultTemplates())
if err != nil {
return nil, fmt.Errorf("failed to access embedded prompts: %w", err)
return nil, fmt.Errorf("failed to initialize prompt store: %w", err)
}

promptStore, err := prompts.NewPromptStore(templatesFS)
localFS, err := fs.Sub(krknPrompts, "prompts")
if err != nil {
return nil, fmt.Errorf("failed to initialize prompt store: %w", err)
return nil, fmt.Errorf("failed to load krkn-ai prompt templates: %w", err)
}
if err := promptStore.RegisterTemplates(localFS); err != nil {
return nil, fmt.Errorf("failed to register krkn-ai prompt templates: %w", err)
}

client, err := llm.NewGeminiClient(ctx, config.APIKey)
Expand All @@ -88,6 +98,12 @@ func New(ctx context.Context, config *Config) (*Engine, error) {
}, nil
}

// WithClusterInfo sets cluster metadata on the aggregator for inclusion in collected data.
func (e *Engine) WithClusterInfo(info *krknAggregator.ClusterInfo) *Engine {
e.aggregator.WithClusterInfo(info)
return e
}

// Run executes the krkn-ai analysis workflow.
func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
// Collect krkn-ai results
Expand All @@ -99,7 +115,7 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
// Create tool registry with log artifacts for read_file tool
toolRegistry := tools.NewRegistry(data.LogArtifacts)

// Prepare template variables
// Prepare template variables from collected data
vars := map[string]any{
"Summary": data.Summary,
"TopScenarios": data.TopScenarios,
Expand All @@ -108,6 +124,9 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
"LogArtifacts": data.LogArtifacts,
"ConfigSummary": data.ConfigSummary,
}
if data.ClusterInfo != nil {
vars["ClusterInfo"] = data.ClusterInfo
}

// Render prompt using prompt store
userPrompt, llmConfig, err := e.promptStore.RenderPrompt(krknAIPromptTemplate, vars)
Expand All @@ -134,10 +153,19 @@ func (e *Engine) Run(ctx context.Context) (*analysisengine.Result, error) {
return nil, fmt.Errorf("LLM analysis failed: %w", err)
}

content := result.Content
if e.config.ReportFormat == "html" {
var err error
content, err = markdownToHTML(content)
if err != nil {
return nil, fmt.Errorf("failed to convert markdown to HTML: %w", err)
}
}

// Build analysis result
analysisResult := &analysisengine.Result{
Status: "completed",
Content: result.Content,
Content: content,
Prompt: userPrompt,
Metadata: map[string]any{
"analysis_type": "krknai",
Expand Down Expand Up @@ -181,6 +209,7 @@ func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregato
summary := map[string]any{
"timestamp": time.Now().Format(time.RFC3339),
"analysis_type": "krknai",
"cluster_info": data.ClusterInfo,
"run_summary": map[string]any{
"total_scenarios": data.Summary.TotalScenarioCount,
"successful_scenarios": data.Summary.SuccessfulScenarioCount,
Expand Down Expand Up @@ -212,6 +241,30 @@ func (e *Engine) writeSummary(result *analysisengine.Result, data *krknAggregato
return nil
}

func markdownToHTML(content string) (string, error) {
htmlTmplBytes, err := krknPrompts.ReadFile(htmlTemplatePath)
if err != nil {
return "", fmt.Errorf("failed to read HTML template: %w", err)
}

tmpl, err := template.New("report").Parse(string(htmlTmplBytes))
if err != nil {
return "", fmt.Errorf("failed to parse HTML template: %w", err)
}

p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags | mdhtml.HrefTargetBlank})
unsafeBody := markdown.ToHTML([]byte(content), p, renderer)
safeBody := bluemonday.UGCPolicy().SanitizeBytes(unsafeBody)

var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ Body template.HTML }{Body: template.HTML(string(safeBody))}); err != nil {
return "", fmt.Errorf("failed to execute HTML template: %w", err)
}

return buf.String(), nil
}

// sendNotifications sends analysis results to configured reporters.
func (e *Engine) sendNotifications(ctx context.Context, result *analysisengine.Result) {
reporterResult := &reporter.AnalysisResult{
Expand Down
Loading