From d5ce8c2fac442a079949c109d7ad3fad2c105eec Mon Sep 17 00:00:00 2001 From: Naeel Date: Thu, 12 Mar 2026 08:29:05 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20AI-=D0=B0=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=20terraform=20plan=20(=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B8=200?= =?UTF-8?q?-5,=20Google=20Gemini)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/sless-plan/main.go | 150 +++++++++++++++++++++++++++++++++++ internal/ai/analyzer.go | 85 ++++++++++++++++++++ internal/ai/cloud_llm.go | 40 ++++++++++ internal/ai/google_gemini.go | 125 +++++++++++++++++++++++++++++ internal/ai/prompts.go | 76 ++++++++++++++++++ internal/ai/provider.go | 53 +++++++++++++ 6 files changed, 529 insertions(+) create mode 100644 cmd/sless-plan/main.go create mode 100644 internal/ai/analyzer.go create mode 100644 internal/ai/cloud_llm.go create mode 100644 internal/ai/google_gemini.go create mode 100644 internal/ai/prompts.go create mode 100644 internal/ai/provider.go diff --git a/cmd/sless-plan/main.go b/cmd/sless-plan/main.go new file mode 100644 index 0000000..4a49054 --- /dev/null +++ b/cmd/sless-plan/main.go @@ -0,0 +1,150 @@ +// cmd/sless-plan/main.go +// Дата: 2026-03-12 +// CLI-обёртка над `terraform plan`. +// Добавляет AI-анализ вывода если AI_HINT_LEVEL > 0. +// Существующий terraform plan не меняется — AI только дополняет вывод. +// +// Использование: +// sless-plan [terraform args...] +// sless-plan --ai-level=3 [terraform args...] +// +// Env-переменные: +// AI_HINT_LEVEL — уровень анализа 0–5 (0 = выключен, по умолчанию) +// AI_PROVIDER — провайдер: "google" (по умолчанию) или "cloud" +// GOOGLE_AI_API_KEY — API ключ Google Gemini +// GOOGLE_AI_MODEL — модель (по умолчанию "gemini-2.0-flash") +// CLOUD_LLM_ENDPOINT — endpoint будущего облачного LLM +// CLOUD_LLM_TOKEN — токен для облачного LLM + +package main + +import ( + "bytes" + "context" + "flag" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "time" + + "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/ai" +) + +func main() { + // Флаг --ai-level переопределяет env AI_HINT_LEVEL + aiLevel := flag.Int("ai-level", -1, "уровень AI-анализа 0–5 (-1 = читать из AI_HINT_LEVEL)") + flag.Parse() + + // Определяем уровень: флаг > env > 0 + level := resolveLevel(*aiLevel) + + // Собираем аргументы для terraform plan (всё что после флагов sless-plan) + tfArgs := append([]string{"plan"}, flag.Args()...) + + // Запускаем terraform plan, захватывая вывод для AI + // но одновременно выводя его пользователю (tee-эффект) + planOutput, exitCode := runTerraform(tfArgs) + + // Если уровень > 0 — запускаем AI анализ + if level > 0 { + runAIAnalysis(level, planOutput) + } + + os.Exit(exitCode) +} + +// resolveLevel определяет уровень AI анализа. +// Приоритет: --ai-level flag > AI_HINT_LEVEL env > 0 +func resolveLevel(flagValue int) int { + if flagValue >= 0 { + // Флаг явно задан + if flagValue > 5 { + flagValue = 5 + } + return flagValue + } + + // Читаем из env + if envVal := os.Getenv("AI_HINT_LEVEL"); envVal != "" { + level, err := strconv.Atoi(envVal) + if err == nil && level >= 0 && level <= 5 { + return level + } + } + + // TF_VAR_ai_hint_level тоже поддерживаем + if envVal := os.Getenv("TF_VAR_ai_hint_level"); envVal != "" { + level, err := strconv.Atoi(envVal) + if err == nil && level >= 0 && level <= 5 { + return level + } + } + + return 0 // по умолчанию — AI выключен +} + +// runTerraform запускает terraform с переданными аргументами. +// Вывод идёт напрямую в stdout/stderr пользователя И захватывается для AI. +// Возвращает текст вывода и код завершения. +func runTerraform(args []string) (string, int) { + cmd := exec.Command("terraform", args...) + + // Захватываем stdout для AI, но также пишем напрямую в os.Stdout + var buf bytes.Buffer + cmd.Stdout = io.MultiWriter(os.Stdout, &buf) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + err := cmd.Run() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return buf.String(), exitErr.ExitCode() + } + fmt.Fprintf(os.Stderr, "ошибка запуска terraform: %v\n", err) + return buf.String(), 1 + } + + return buf.String(), 0 +} + +// runAIAnalysis запускает AI анализ и выводит результат. +// Не прерывает работу если AI недоступен — только показывает предупреждение. +func runAIAnalysis(level int, planOutput string) { + cfg := ai.Config{ + HintLevel: level, + Provider: getEnv("AI_PROVIDER", "google"), + GoogleAPIKey: os.Getenv("GOOGLE_AI_API_KEY"), + GoogleModel: getEnv("GOOGLE_AI_MODEL", "gemini-2.0-flash"), + CloudEndpoint: os.Getenv("CLOUD_LLM_ENDPOINT"), + CloudToken: os.Getenv("CLOUD_LLM_TOKEN"), + } + + analyzer, err := ai.NewAnalyzer(cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "\n⚠ AI анализ недоступен: %v\n", err) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result, err := analyzer.Analyze(ctx, planOutput) + if err != nil { + fmt.Fprintf(os.Stderr, "\n⚠ AI анализ завершился с ошибкой: %v\n", err) + return + } + + if result != "" { + fmt.Print(result) + } +} + +// getEnv возвращает значение env-переменной или defaultVal если не задана. +func getEnv(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/internal/ai/analyzer.go b/internal/ai/analyzer.go new file mode 100644 index 0000000..c30a810 --- /dev/null +++ b/internal/ai/analyzer.go @@ -0,0 +1,85 @@ +// internal/ai/analyzer.go +// Дата: 2026-03-12 +// Оркестратор AI-анализа: читает уровень, формирует промпт, +// вызывает LLM-провайдер, форматирует вывод для пользователя. + +package ai + +import ( + "context" + "fmt" + "strings" +) + +// Analyzer — основной компонент AI-анализа. +// Создаётся один раз при старте sless-plan, используется для одного анализа. +type Analyzer struct { + provider LLMProvider + level int +} + +// NewAnalyzer создаёт Analyzer из конфига. +// Возвращает ошибку если провайдер не может быть инициализирован. +func NewAnalyzer(cfg Config) (*Analyzer, error) { + if cfg.HintLevel == 0 { + // Уровень 0 — AI выключен, провайдер не нужен + return &Analyzer{level: 0}, nil + } + + provider, err := NewProvider(cfg) + if err != nil { + return nil, fmt.Errorf("ошибка инициализации AI провайдера: %w", err) + } + + return &Analyzer{ + provider: provider, + level: cfg.HintLevel, + }, nil +} + +// IsEnabled возвращает true если уровень > 0 и провайдер инициализирован. +func (a *Analyzer) IsEnabled() bool { + return a.level > 0 && a.provider != nil +} + +// Analyze отправляет вывод terraform plan в LLM и возвращает отформатированный результат. +// planText — текстовый вывод terraform plan. +func (a *Analyzer) Analyze(ctx context.Context, planText string) (string, error) { + if !a.IsEnabled() { + return "", nil + } + + // Ограничиваем размер payload — большие планы обрезаем + // чтобы не превысить лимит токенов LLM и не отправлять лишнее + planText = truncatePlan(planText, 8000) + + prompt := BuildPrompt(a.level, planText) + result, err := a.provider.Analyze(ctx, prompt) + if err != nil { + return "", fmt.Errorf("AI анализ не удался: %w", err) + } + + return FormatResult(a.level, a.provider.Name(), result), nil +} + +// FormatResult оборачивает ответ LLM в читаемый блок для вывода в терминал. +func FormatResult(level int, providerName, result string) string { + separator := strings.Repeat("─", 60) + header := fmt.Sprintf("AI Analysis (level %d: %s) via %s", level, LevelName(level), providerName) + return fmt.Sprintf("\n%s\n %s\n%s\n%s\n%s\n", + separator, + header, + separator, + result, + separator, + ) +} + +// truncatePlan обрезает текст плана до maxChars символов с пометкой об обрезке. +// Нужно чтобы не превышать лимиты токенов LLM и не отправлять лишние данные. +func truncatePlan(text string, maxChars int) string { + if len(text) <= maxChars { + return text + } + return text[:maxChars] + fmt.Sprintf("\n\n[... обрезано, показаны первые %d символов из %d ...]", maxChars, len(text)) +} diff --git a/internal/ai/cloud_llm.go b/internal/ai/cloud_llm.go new file mode 100644 index 0000000..758232b --- /dev/null +++ b/internal/ai/cloud_llm.go @@ -0,0 +1,40 @@ +// internal/ai/cloud_llm.go +// Дата: 2026-03-12 +// Заглушка для будущего облачного LLM. +// Когда облако предоставит свой LLM — реализовать этот файл +// и переключить AI_PROVIDER=cloud. Остальной код не меняется. + +package ai + +import ( + "context" + "fmt" +) + +// CloudLLMProvider — будущая реализация LLMProvider для облачного LLM. +// Пока возвращает ошибку "не реализован". +type CloudLLMProvider struct { + endpoint string + token string +} + +// NewCloudLLMProvider создаёт заглушку провайдера облачного LLM. +func NewCloudLLMProvider(endpoint, token string) (*CloudLLMProvider, error) { + if endpoint == "" { + return nil, fmt.Errorf("CLOUD_LLM_ENDPOINT не задан") + } + return &CloudLLMProvider{ + endpoint: endpoint, + token: token, + }, nil +} + +func (c *CloudLLMProvider) Name() string { + return fmt.Sprintf("cloud-llm/%s", c.endpoint) +} + +// Analyze — TODO: реализовать когда облако предоставит LLM API. +// Документация по API будет в doc/api/cloud-llm.md. +func (c *CloudLLMProvider) Analyze(_ context.Context, _ string) (string, error) { + return "", fmt.Errorf("cloud LLM провайдер ещё не реализован (endpoint: %s)", c.endpoint) +} diff --git a/internal/ai/google_gemini.go b/internal/ai/google_gemini.go new file mode 100644 index 0000000..e2046fd --- /dev/null +++ b/internal/ai/google_gemini.go @@ -0,0 +1,125 @@ +// internal/ai/google_gemini.go +// Дата: 2026-03-12 +// Реализация LLMProvider для Google Gemini API. +// Использует REST API напрямую через net/http — без внешних SDK, +// чтобы не нарушать совместимость существующего go.mod. + +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const geminiBaseURL = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent" + +// GeminiProvider реализует LLMProvider через Google Gemini REST API. +type GeminiProvider struct { + apiKey string + model string + httpClient *http.Client +} + +// NewGeminiProvider создаёт провайдера для Google Gemini. +// apiKey — значение env GOOGLE_AI_API_KEY. +func NewGeminiProvider(apiKey, model string) (*GeminiProvider, error) { + if apiKey == "" { + return nil, fmt.Errorf("GOOGLE_AI_API_KEY не задан") + } + return &GeminiProvider{ + apiKey: apiKey, + model: model, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + }, nil +} + +func (g *GeminiProvider) Name() string { + return fmt.Sprintf("google-gemini/%s", g.model) +} + +// geminiRequest — структура запроса к Gemini API. +type geminiRequest struct { + Contents []geminiContent `json:"contents"` +} + +type geminiContent struct { + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text"` +} + +// geminiResponse — структура ответа от Gemini API. +type geminiResponse struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + Error *struct { + Message string `json:"message"` + Code int `json:"code"` + } `json:"error,omitempty"` +} + +// Analyze отправляет промпт в Gemini и возвращает текстовый ответ. +func (g *GeminiProvider) Analyze(ctx context.Context, prompt string) (string, error) { + reqBody := geminiRequest{ + Contents: []geminiContent{ + {Parts: []geminiPart{{Text: prompt}}}, + }, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("ошибка маршалинга запроса: %w", err) + } + + url := fmt.Sprintf(geminiBaseURL+"?key=%s", g.model, g.apiKey) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return "", fmt.Errorf("ошибка создания HTTP запроса: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := g.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("ошибка HTTP запроса к Gemini: %w", err) + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("ошибка чтения ответа: %w", err) + } + + var gemResp geminiResponse + if err := json.Unmarshal(respBytes, &gemResp); err != nil { + return "", fmt.Errorf("ошибка парсинга ответа Gemini: %w", err) + } + + // Проверяем HTTP-ошибку и ошибку в теле ответа + if resp.StatusCode != http.StatusOK { + msg := fmt.Sprintf("HTTP %d", resp.StatusCode) + if gemResp.Error != nil { + msg = gemResp.Error.Message + } + return "", fmt.Errorf("Gemini API ошибка: %s", msg) + } + + if len(gemResp.Candidates) == 0 || len(gemResp.Candidates[0].Content.Parts) == 0 { + return "", fmt.Errorf("Gemini вернул пустой ответ") + } + + return gemResp.Candidates[0].Content.Parts[0].Text, nil +} diff --git a/internal/ai/prompts.go b/internal/ai/prompts.go new file mode 100644 index 0000000..51ffbc3 --- /dev/null +++ b/internal/ai/prompts.go @@ -0,0 +1,76 @@ +// internal/ai/prompts.go +// Дата: 2026-03-12 +// Шаблоны system-промптов для каждого уровня анализа (1–5). +// Уровень 0 — AI не вызывается вообще (проверка в analyzer.go). + +package ai + +import "fmt" + +// systemPrompts — system prompt для каждого уровня. +// Каждый уровень строго ограничен своей областью — не расширять без явного указания. +var systemPrompts = map[int]string{ + 1: `Ты — анализатор Terraform конфигурации. Твоя задача: ТОЛЬКО синтаксический анализ. +Найди синтаксические ошибки в HCL: опечатки в ключевых словах, незакрытые блоки, неверные типы значений, невалидные атрибуты. +НЕ давай рекомендаций по улучшению, НЕ анализируй логику, НЕ комментируй архитектуру. +Формат ответа: список найденных ошибок или "✓ Синтаксических ошибок не обнаружено."`, + + 2: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы. +1. Синтаксические ошибки в HCL. +2. Базовые проблемы: неиспользуемые переменные/ресурсы, пустые обязательные значения, дублирующиеся ресурсы. +НЕ анализируй зависимости, архитектуру, безопасность. +Формат: список проблем с пометкой [SYNTAX] или [BASIC], или "✓ Проблем не обнаружено."`, + + 3: `Ты — анализатор Terraform конфигурации. Твоя задача: синтаксис + базовые проблемы + зависимости. +1. Синтаксические ошибки. +2. Базовые проблемы (неиспользуемые ресурсы, пустые значения). +3. Анализ зависимостей: потенциальные race conditions при apply/destroy, неявные зависимости, проблемы с порядком создания ресурсов. +НЕ давай рекомендаций по безопасности или рефакторингу. +Формат: список проблем с пометкой [SYNTAX], [BASIC] или [DEPS], или "✓ Проблем не обнаружено."`, + + 4: `Ты — анализатор Terraform конфигурации. Твоя задача: полный анализ + безопасность. +1. Синтаксические ошибки. +2. Базовые проблемы. +3. Зависимости и порядок операций. +4. Безопасность и best practices: открытые порты (0.0.0.0/0), избыточные права доступа, незашифрованные ресурсы, жёстко заданные секреты. +Формат: список проблем с пометкой [SYNTAX], [BASIC], [DEPS] или [SECURITY], или "✓ Проблем не обнаружено."`, + + 5: `Ты — старший DevOps-инженер, проводишь полный аудит Terraform конфигурации. +Анализируй: +1. Синтаксические ошибки. +2. Базовые проблемы. +3. Зависимости и порядок операций. +4. Безопасность и best practices. +5. Оптимизация: возможности для рефакторинга, использование модулей, устаревшие паттерны, альтернативные подходы. +Давай конкретные рекомендации с примерами кода где уместно. +Формат: список проблем и рекомендаций с пометкой [SYNTAX], [BASIC], [DEPS], [SECURITY] или [OPT].`, +} + +// levelNames — человекочитаемые названия уровней для вывода. +var levelNames = map[int]string{ + 0: "OFF", + 1: "SYNTAX", + 2: "BASIC", + 3: "MODERATE", + 4: "DETAILED", + 5: "FULL", +} + +// BuildPrompt формирует финальный промпт для отправки в LLM. +// planText — вывод terraform plan (текст или JSON). +func BuildPrompt(level int, planText string) string { + sysPrompt, ok := systemPrompts[level] + if !ok { + sysPrompt = systemPrompts[1] + } + return fmt.Sprintf("%s\n\nTerraform plan output:\n```\n%s\n```", sysPrompt, planText) +} + +// LevelName возвращает название уровня для вывода пользователю. +func LevelName(level int) string { + name, ok := levelNames[level] + if !ok { + return "UNKNOWN" + } + return name +} diff --git a/internal/ai/provider.go b/internal/ai/provider.go new file mode 100644 index 0000000..fa2f7f5 --- /dev/null +++ b/internal/ai/provider.go @@ -0,0 +1,53 @@ +// internal/ai/provider.go +// Дата: 2026-03-12 +// Интерфейс LLMProvider — абстракция над любым LLM-бэкендом. +// Сейчас реализован Google Gemini. В будущем — облачный LLM (drop-in замена). + +package ai + +import "context" + +// LLMProvider — единственный интерфейс для всех LLM-провайдеров. +// Менять провайдер = менять одну env-переменную AI_PROVIDER. +type LLMProvider interface { + // Analyze отправляет промпт в LLM и возвращает текстовый ответ. + Analyze(ctx context.Context, prompt string) (string, error) + // Name возвращает имя провайдера для логирования. + Name() string +} + +// Config — конфигурация анализатора, читается из env-переменных. +type Config struct { + // Provider: "google" или "cloud" (в будущем) + Provider string + // HintLevel: 0–5, если 0 — AI не вызывается + HintLevel int + // Google Gemini + GoogleAPIKey string + GoogleModel string // по умолчанию "gemini-pro" + // Будущий облачный LLM + CloudEndpoint string + CloudToken string +} + +// NewProvider — фабрика провайдеров. Выбор по Config.Provider. +// При добавлении нового провайдера — только сюда добавить case. +func NewProvider(cfg Config) (LLMProvider, error) { + switch cfg.Provider { + case "google": + model := cfg.GoogleModel + if model == "" { + model = "gemini-2.0-flash" + } + return NewGeminiProvider(cfg.GoogleAPIKey, model) + case "cloud": + return NewCloudLLMProvider(cfg.CloudEndpoint, cfg.CloudToken) + default: + // По умолчанию google, если AI_PROVIDER не задан + model := cfg.GoogleModel + if model == "" { + model = "gemini-2.0-flash" + } + return NewGeminiProvider(cfg.GoogleAPIKey, model) + } +}