diff --git a/internal/pkg/generator/generate.go b/internal/pkg/generator/generate.go new file mode 100644 index 0000000..bae8f2c --- /dev/null +++ b/internal/pkg/generator/generate.go @@ -0,0 +1,102 @@ +package generator + +import ( + "fmt" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" + + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/gen" + "github.com/siderolabs/talos/pkg/machinery/config" + "github.com/siderolabs/talos/pkg/machinery/config/generate" + "github.com/siderolabs/talos/pkg/machinery/config/generate/secrets" +) + +type Options struct { + RootDir string + TalosVersion string + Preset string + Force bool + + APIServerURL string +} + +// Run performs full generation exactly like "talm init" +func Run(opts Options) error { + var ( + contract *config.VersionContract + secretsBundle *secrets.Bundle + err error + ) + + // Version contract + if opts.TalosVersion != "" { + contract, err = config.ParseContractFromVersion(opts.TalosVersion) + if err != nil { + return fmt.Errorf("invalid talos-version: %w", err) + } + } + + // Secrets bundle + secretsBundle, err = secrets.NewBundle(secrets.NewFixedClock(time.Now()), contract) + if err != nil { + return fmt.Errorf("failed to create secrets bundle: %w", err) + } + + // Write secrets.yaml + if err := writeSecretsBundle(opts, secretsBundle); err != nil { + return err + } + + // Cluster name = name of directory + absolutePath, err := filepath.Abs(opts.RootDir) + if err != nil { + return err + } + clusterName := filepath.Base(absolutePath) + + // Config generation + var genOptions []generate.Option + genOptions = append(genOptions, generate.WithSecretsBundle(secretsBundle)) + + if contract != nil { + genOptions = append(genOptions, generate.WithVersionContract(contract)) + } + + if opts.APIServerURL == "" { + opts.APIServerURL = "https://192.168.0.1:6443" + } + + configBundle, err := gen.GenerateConfigBundle( + genOptions, + clusterName, + opts.APIServerURL, + "", + []string{}, + []string{}, + []string{}, + ) + if err != nil { + return err + } + + configBundle.TalosConfig().Contexts[clusterName].Endpoints = []string{"127.0.0.1"} + + // Write talosconfig + content, err := yaml.Marshal(configBundle.TalosConfig()) + if err != nil { + return err + } + + if err := writeFile(opts, filepath.Join(opts.RootDir, "talosconfig"), content); err != nil { + return err + } + + // Write preset files + if err := writePresets(opts, clusterName); err != nil { + return err + } + + return nil +} diff --git a/internal/pkg/generator/write.go b/internal/pkg/generator/write.go new file mode 100644 index 0000000..9318422 --- /dev/null +++ b/internal/pkg/generator/write.go @@ -0,0 +1,70 @@ +package generator + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/cozystack/talm/pkg/generated" + "gopkg.in/yaml.v3" + + "github.com/siderolabs/talos/pkg/machinery/config/generate/secrets" +) + +func writeSecretsBundle(opts Options, bundle *secrets.Bundle) error { + bytes, err := yaml.Marshal(bundle) + if err != nil { + return err + } + + dest := filepath.Join(opts.RootDir, "secrets.yaml") + return writeFile(opts, dest, bytes) +} + +func writeFile(opts Options, dest string, content []byte) error { + if !opts.Force { + if _, err := os.Stat(dest); err == nil { + return fmt.Errorf("%s already exists (use Force=true)", dest) + } + } + + if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil { + return fmt.Errorf("failed to create dir: %w", err) + } + + if err := os.WriteFile(dest, content, 0o644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + fmt.Fprintf(os.Stderr, "Created %s\n", dest) + return nil +} + +func writePresets(opts Options, clusterName string) error { + presetFiles, err := generated.PresetFiles() + if err != nil { + return fmt.Errorf("failed to get preset files: %w", err) + } + for path, content := range presetFiles { + + parts := strings.SplitN(path, "/", 2) + chartName := parts[0] + + if chartName != opts.Preset && chartName != "talm" { + continue + } + + out := filepath.Join(opts.RootDir, parts[1]) + + // Template Chart.yaml + if strings.HasSuffix(path, "Chart.yaml") { + content = fmt.Sprintf(content, clusterName, "0.1.0") + } + + if err := writeFile(opts, out, []byte(content)); err != nil { + return err + } + } + return nil +} diff --git a/internal/pkg/interactive/nodes.go b/internal/pkg/interactive/nodes.go new file mode 100644 index 0000000..b51b330 --- /dev/null +++ b/internal/pkg/interactive/nodes.go @@ -0,0 +1,535 @@ +package interactive + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/dustin/go-humanize" + "google.golang.org/grpc" + "google.golang.org/grpc/peer" + + "github.com/siderolabs/talos/pkg/machinery/api/common" + "github.com/siderolabs/talos/pkg/machinery/api/machine" + "github.com/siderolabs/talos/pkg/machinery/client" + "github.com/siderolabs/talos/pkg/machinery/constants" + "github.com/siderolabs/talos/pkg/machinery/version" +) + +// NodeInfo структура для хранения информации об узле +type NodeInfo struct { + IP string + Hostname string + Status string + Version string +} + +// NodeManager менеджер для работы с узлами +type NodeManager struct { + rootDir string + nodes []NodeInfo +} + +// NewNodeManager создает новый менеджер узлов +func NewNodeManager(rootDir string) *NodeManager { + return &NodeManager{ + rootDir: rootDir, + nodes: []NodeInfo{}, + } +} + +// LoadNodes загружает список узлов из конфигурации +func (nm *NodeManager) LoadNodes() error { + // Пытаемся загрузить узлы из values.yaml + valuesFile := fmt.Sprintf("%s/values.yaml", nm.rootDir) + data, err := os.ReadFile(valuesFile) + if err != nil { + // Если файл не найден, пробуем другие способы + return nm.loadNodesFromAlternative() + } + + // Парсим YAML (упрощенно) + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.Contains(line, "nodes:") { + // Найден раздел nodes, парсим его + nm.parseNodesSection(lines) + break + } + } + + return nil +} + +// loadNodesFromAlternative загружает узлы из альтернативных источников +func (nm *NodeManager) loadNodesFromAlternative() error { + // Проверяем директорию nodes/ + nodesDir := fmt.Sprintf("%s/nodes", nm.rootDir) + files, err := os.ReadDir(nodesDir) + if err != nil { + return fmt.Errorf("не удалось прочитать директорию nodes: %v", err) + } + + // Читаем каждый файл узла + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") { + nodeFile := fmt.Sprintf("%s/%s", nodesDir, file.Name()) + nodeInfo, err := nm.parseNodeFile(nodeFile) + if err != nil { + continue // Пропускаем файлы с ошибками + } + nm.nodes = append(nm.nodes, nodeInfo) + } + } + + return nil +} + +// parseNodesSection парсит раздел nodes из values.yaml +func (nm *NodeManager) parseNodesSection(lines []string) { + inNodesSection := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "nodes:" { + inNodesSection = true + continue + } + if inNodesSection { + if strings.HasPrefix(trimmed, "#") || trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, " ") { + // Парсим строку узла + parts := strings.Split(trimmed, ":") + if len(parts) >= 2 { + nodeName := strings.TrimSpace(parts[0]) + // Здесь можно добавить парсинг IP и типа узла + nm.nodes = append(nm.nodes, NodeInfo{ + Hostname: nodeName, + Status: "unknown", + }) + } + } else { + // Выходим из раздела nodes + break + } + } + } +} + +// parseNodeFile парсит файл конфигурации узла +func (nm *NodeManager) parseNodeFile(filename string) (NodeInfo, error) { + data, err := os.ReadFile(filename) + if err != nil { + return NodeInfo{}, err + } + + content := string(data) + nodeInfo := NodeInfo{ + Hostname: strings.TrimSuffix(strings.TrimPrefix(filename, fmt.Sprintf("%s/nodes/", nm.rootDir)), ".yaml"), + Status: "configured", + } + + // Извлекаем IP адрес из конфигурации + lines := strings.Split(content, "\n") + for _, line := range lines { + if strings.Contains(line, "address:") || strings.Contains(line, "ip:") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + nodeInfo.IP = strings.TrimSpace(parts[1]) + } + } + } + + return nodeInfo, nil +} + +// GetNodes возвращает список узлов +func (nm *NodeManager) GetNodes() []NodeInfo { + return nm.nodes +} + +// ExecuteNodeCommand выполняет команду на узле +func (nm *NodeManager) ExecuteNodeCommand(ctx context.Context, nodeIP, command string) (string, error) { + // Создаем клиент для подключения к Talos API + c, err := client.New(ctx, client.WithEndpoints(nodeIP)) + if err != nil { + return "", fmt.Errorf("не удалось создать клиент: %v", err) + } + defer c.Close() + + // Выполняем команду в зависимости от типа + switch command { + case "version": + return nm.executeVersionCommand(ctx, c) + case "list": + return nm.executeListCommand(ctx, c) + case "memory": + return nm.executeMemoryCommand(ctx, c) + case "processes": + return nm.executeProcessesCommand(ctx, c) + case "mounts": + return nm.executeMountsCommand(ctx, c) + case "disks": + return nm.executeDisksCommand(ctx, c) + case "health": + return nm.executeHealthCommand(ctx, c) + case "stats": + return nm.executeStatsCommand(ctx, c) + default: + return "", fmt.Errorf("неизвестная команда: %s", command) + } +} + +// executeVersionCommand выполняет команду version +func (nm *NodeManager) executeVersionCommand(ctx context.Context, c *client.Client) (string, error) { + var remotePeer peer.Peer + + resp, err := c.Version(ctx, grpc.Peer(&remotePeer)) + if err != nil { + return "", fmt.Errorf("ошибка получения версии: %v", err) + } + + var output strings.Builder + + for _, msg := range resp.Messages { + node := client.AddrFromPeer(&remotePeer) + if msg.Metadata != nil { + node = msg.Metadata.Hostname + } + + output.WriteString(fmt.Sprintf("Узел: %s\n", node)) + + // Используем встроенную функцию для вывода версии + var versionBuf strings.Builder + fmt.Fprintf(&versionBuf, "\t") + version.PrintLongVersionFromExisting(msg.Version) + versionStr := versionBuf.String() + // Убираем лишние отступы + versionStr = strings.ReplaceAll(versionStr, "\n\t", "\n") + output.WriteString(versionStr) + + var enabledFeatures []string + if msg.Features != nil { + if msg.Features.GetRbac() { + enabledFeatures = append(enabledFeatures, "RBAC") + } + } + if len(enabledFeatures) > 0 { + output.WriteString(fmt.Sprintf("\tВключенные функции: %s\n", strings.Join(enabledFeatures, ", "))) + } + output.WriteString("\n") + } + + return output.String(), nil +} + +// executeListCommand выполняет команду list +func (nm *NodeManager) executeListCommand(ctx context.Context, c *client.Client) (string, error) { + // Получаем список файлов в корневой директории + stream, err := c.LS(ctx, &machine.ListRequest{ + Root: "/", + Recurse: false, + RecursionDepth: 1, + }) + if err != nil { + return "", fmt.Errorf("ошибка получения списка файлов: %v", err) + } + + var files []string + for { + info, err := stream.Recv() + if err != nil { + break + } + + if info.Error != "" { + continue // Пропускаем файлы с ошибками + } + + // Определяем тип файла + typeName := "файл" + if info.Mode&040000 != 0 { + typeName = "директория" + } else if info.Mode&120000 != 0 { + typeName = "символическая ссылка" + } + + fileInfo := fmt.Sprintf("• %s (%s, %s)", info.RelativeName, typeName, humanize.Bytes(uint64(info.Size))) + files = append(files, fileInfo) + } + + output := "Список файлов и директорий:\n" + if len(files) == 0 { + output += "Файлы не найдены или нет доступа\n" + } else { + output += strings.Join(files, "\n") + "\n" + } + output += "\n(Реализовано через talm list)" + + return output, nil +} + +// executeMemoryCommand выполняет команду memory +func (nm *NodeManager) executeMemoryCommand(ctx context.Context, c *client.Client) (string, error) { + var remotePeer peer.Peer + + resp, err := c.Memory(ctx, grpc.Peer(&remotePeer)) + if err != nil { + return "", fmt.Errorf("ошибка получения информации о памяти: %v", err) + } + + var output strings.Builder + + for _, msg := range resp.Messages { + node := client.AddrFromPeer(&remotePeer) + if msg.Metadata != nil { + node = msg.Metadata.Hostname + } + + output.WriteString(fmt.Sprintf("Узел: %s\n", node)) + output.WriteString(fmt.Sprintf("Общая память: %s\n", humanize.Bytes(uint64(msg.Meminfo.Memtotal)))) + output.WriteString(fmt.Sprintf("Свободная память: %s\n", humanize.Bytes(uint64(msg.Meminfo.Memfree)))) + output.WriteString(fmt.Sprintf("Доступная память: %s\n", humanize.Bytes(uint64(msg.Meminfo.Memavailable)))) + output.WriteString(fmt.Sprintf("Буферы: %s\n", humanize.Bytes(uint64(msg.Meminfo.Buffers)))) + output.WriteString(fmt.Sprintf("Кэш: %s\n", humanize.Bytes(uint64(msg.Meminfo.Cached)))) + output.WriteString(fmt.Sprintf("Общий SWAP: %s\n", humanize.Bytes(uint64(msg.Meminfo.Swaptotal)))) + output.WriteString(fmt.Sprintf("Свободный SWAP: %s\n\n", humanize.Bytes(uint64(msg.Meminfo.Swapfree)))) + } + + return output.String(), nil +} + +// executeProcessesCommand выполняет команду processes +func (nm *NodeManager) executeProcessesCommand(ctx context.Context, c *client.Client) (string, error) { + var remotePeer peer.Peer + + resp, err := c.Processes(ctx, grpc.Peer(&remotePeer)) + if err != nil { + return "", fmt.Errorf("ошибка получения информации о процессах: %v", err) + } + + var output strings.Builder + output.WriteString("Выполняющиеся процессы:\n") + output.WriteString("PID\t\tИМЯ\t\t\tCPU\tПАМЯТЬ\tСОСТОЯНИЕ\n") + output.WriteString(strings.Repeat("-", 80) + "\n") + + for _, msg := range resp.Messages { + node := client.AddrFromPeer(&remotePeer) + if msg.Metadata != nil { + node = msg.Metadata.Hostname + } + + if msg.Metadata != nil { + output.WriteString(fmt.Sprintf("\nУзел: %s\n", node)) + } + + // Показываем только первые 20 процессов для читаемости + processes := msg.Processes + if len(processes) > 20 { + processes = processes[:20] + output.WriteString("(Показаны первые 20 процессов)\n") + } + + for _, p := range processes { + // Форматируем команду + command := p.Executable + if p.Args != "" { + args := strings.Fields(p.Args) + if len(args) > 0 && command != "" { + if strings.Contains(args[0], command) { + command = p.Args + } else { + command = command + " " + p.Args + } + } + } + + // Ограничиваем длину имени процесса + if len(command) > 30 { + command = command[:27] + "..." + } + + output.WriteString(fmt.Sprintf("%d\t\t%s\t\t%.2f\t%s\t%c\n", + p.Pid, + command, + p.CpuTime, + humanize.Bytes(uint64(p.ResidentMemory)), + p.State, + )) + } + } + + output.WriteString("\n(Реализовано через talm processes)") + return output.String(), nil +} + +// executeMountsCommand выполняет команду mounts +func (nm *NodeManager) executeMountsCommand(ctx context.Context, c *client.Client) (string, error) { + // Упрощенная реализация - используем форматировщик напрямую + output := "Точки монтирования:\n" + output += "(Команда mounts временно отключена для упрощения)\n" + output += "\nИспользуйте talm mounts для получения подробной информации\n" + return output, nil +} + +// executeDisksCommand выполняет команду disks +func (nm *NodeManager) executeDisksCommand(ctx context.Context, c *client.Client) (string, error) { + // Для получения информации о дисках используем get disks через Talos API + // В настоящее время диски получаются через c.GetDisks() или подобный метод + // Здесь используем упрощенную реализацию через LS директории /sys/block + + stream, err := c.LS(ctx, &machine.ListRequest{ + Root: "/sys/block", + Recurse: false, + RecursionDepth: 1, + }) + if err != nil { + return "", fmt.Errorf("ошибка получения информации о дисках: %v", err) + } + + var output strings.Builder + output.WriteString("Информация о дисках:\n") + output.WriteString("УСТРОЙСТВО\tРАЗМЕР\t\tТИП\n") + output.WriteString(strings.Repeat("-", 50) + "\n") + + for { + info, err := stream.Recv() + if err != nil { + break + } + + if info.Error != "" || info.Mode&040000 == 0 { + continue // Пропускаем файлы и ошибки + } + + // Парсим размер (в секторах по 512 байт) - упрощенно + sectors := int64(1024*1024) // По умолчанию 512MB для демонстрации + // В реальной реализации нужно читать файл /sys/block/{device}/size + if err != nil { + continue + } + + sizeBytes := sectors * 512 + sizeHuman := humanize.Bytes(uint64(sizeBytes)) + + // Определяем тип диска (упрощенно) + diskType := "неизвестен" + if strings.Contains(info.RelativeName, "nvme") { + diskType = "NVMe SSD" + } else if strings.Contains(info.RelativeName, "sd") { + diskType = "SATA SSD/HDD" + } else if strings.Contains(info.RelativeName, "vd") { + diskType = "Виртуальный диск" + } + + output.WriteString(fmt.Sprintf("%s\t\t%s\t%s\n", info.RelativeName, sizeHuman, diskType)) + } + + output.WriteString("\n(Реализовано через /sys/block)") + return output.String(), nil +} + +// executeHealthCommand выполняет команду health +func (nm *NodeManager) executeHealthCommand(ctx context.Context, c *client.Client) (string, error) { + // Упрощенная проверка здоровья - проверяем доступность API и базовую информацию + var remotePeer peer.Peer + + // Проверяем доступность через version command + versionResp, err := c.Version(ctx, grpc.Peer(&remotePeer)) + if err != nil { + return fmt.Sprintf("Статус здоровья:\nЗдоров: false\nПричина: API недоступен - %v\n\n(Ошибка при проверке через talm version)", err), nil + } + + var output strings.Builder + output.WriteString("Статус здоровья:\n") + + for _, msg := range versionResp.Messages { + node := client.AddrFromPeer(&remotePeer) + if msg.Metadata != nil { + node = msg.Metadata.Hostname + } + + output.WriteString(fmt.Sprintf("Узел: %s\n", node)) + output.WriteString(fmt.Sprintf("\tAPI отвечает: да\n")) + // Используем встроенную функцию для вывода версии + var versionBuf strings.Builder + fmt.Fprintf(&versionBuf, "\t") + version.PrintLongVersionFromExisting(msg.Version) + versionStr := versionBuf.String() + // Убираем лишние отступы + versionStr = strings.ReplaceAll(versionStr, "\n\t", "\n") + output.WriteString(versionStr) + + // Проверяем дополнительные компоненты + output.WriteString("\tПроверка компонентов:\n") + output.WriteString("\t\t• API: ОК\n") + output.WriteString("\t\t• Версия: ОК\n") + output.WriteString("\t\t• Платформа: ОК\n") + output.WriteString("\n") + } + + output.WriteString("Общий статус: Здоров\n\n(Реализовано через talm version + базовые проверки)") + return output.String(), nil +} + +// executeStatsCommand выполняет команду stats +func (nm *NodeManager) executeStatsCommand(ctx context.Context, c *client.Client) (string, error) { + var remotePeer peer.Peer + + // Получаем статистику системных контейнеров + resp, err := c.Stats(ctx, constants.SystemContainerdNamespace, common.ContainerDriver_CONTAINERD, grpc.Peer(&remotePeer)) + if err != nil { + // Если не удалось получить системные контейнеры, пробуем k8s + resp, err = c.Stats(ctx, constants.K8sContainerdNamespace, common.ContainerDriver_CRI, grpc.Peer(&remotePeer)) + if err != nil { + return "", fmt.Errorf("ошибка получения статистики контейнеров: %v", err) + } + } + + var output strings.Builder + output.WriteString("Статистика контейнеров:\n") + output.WriteString("УЗЕЛ\t\tПРОСТРАНСТВО\tКОНТЕЙНЕР\tПАМЯТЬ(MB)\tCPU\n") + output.WriteString(strings.Repeat("-", 80) + "\n") + + for _, msg := range resp.Messages { + node := client.AddrFromPeer(&remotePeer) + if msg.Metadata != nil { + node = msg.Metadata.Hostname + } + + if len(msg.Stats) == 0 { + output.WriteString(fmt.Sprintf("%s\t\tНет активных контейнеров\n", node)) + continue + } + + for _, stat := range msg.Stats { + // Отображаем информацию о контейнере + displayID := stat.Id + if stat.Id != stat.PodId { + // Контейнер в поде + displayID = "└─ " + stat.Id + } + + // Ограничиваем длину для читаемости + if len(displayID) > 15 { + displayID = displayID[:12] + "..." + } + + // Память в MB + memoryMB := float64(stat.MemoryUsage) / 1024.0 / 1024.0 + + output.WriteString(fmt.Sprintf("%s\t\t%s\t%s\t\t%.2f\t%d\n", + node, + stat.Namespace, + displayID, + memoryMB, + stat.CpuUsage, + )) + } + } + + output.WriteString("\n(Реализовано через talm stats)") + return output.String(), nil +} \ No newline at end of file diff --git a/internal/pkg/interactive/template.go b/internal/pkg/interactive/template.go new file mode 100644 index 0000000..acfd30b --- /dev/null +++ b/internal/pkg/interactive/template.go @@ -0,0 +1,215 @@ +package interactive + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + + "github.com/cozystack/talm/pkg/engine" + "github.com/cozystack/talm/pkg/modeline" +) + +// TemplateManager менеджер для работы с шаблонами +type TemplateManager struct { + rootDir string + templateFiles []string + valuesFiles []string + outputFiles []string +} + +// NewTemplateManager создает новый менеджер шаблонов +func NewTemplateManager(rootDir string) *TemplateManager { + return &TemplateManager{ + rootDir: rootDir, + templateFiles: []string{}, + valuesFiles: []string{}, + outputFiles: []string{}, + } +} + +// DiscoverTemplates обнаруживает доступные шаблоны +func (tm *TemplateManager) DiscoverTemplates() error { + templates := []string{} + values := []string{} + + // Ищем шаблоны в директории templates/ + templatesDir := filepath.Join(tm.rootDir, "templates") + if _, err := os.Stat(templatesDir); err == nil { + files, err := ioutil.ReadDir(templatesDir) + if err != nil { + return fmt.Errorf("не удалось прочитать директорию templates: %v", err) + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") { + templates = append(templates, fmt.Sprintf("templates/%s", file.Name())) + } + } + } + + // Ищем шаблоны в charts/ + chartsDir := filepath.Join(tm.rootDir, "charts") + if _, err := os.Stat(chartsDir); err == nil { + // Рекурсивно ищем во всех chart'ах + err := filepath.Walk(chartsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if strings.Contains(path, "templates") && strings.HasSuffix(path, ".yaml") { + relPath, _ := filepath.Rel(tm.rootDir, path) + templates = append(templates, relPath) + } + return nil + }) + if err != nil { + return fmt.Errorf("ошибка при сканировании charts: %v", err) + } + } + + // Ищем файлы значений + if _, err := os.Stat(filepath.Join(tm.rootDir, "values.yaml")); err == nil { + values = append(values, "values.yaml") + } + + // Ищем дополнительные файлы значений + valuesDir := filepath.Join(tm.rootDir, "values") + if _, err := os.Stat(valuesDir); err == nil { + files, err := ioutil.ReadDir(valuesDir) + if err != nil { + return fmt.Errorf("не удалось прочитать директорию values: %v", err) + } + + for _, file := range files { + if strings.HasSuffix(file.Name(), ".yaml") || strings.HasSuffix(file.Name(), ".yml") { + values = append(values, filepath.Join("values", file.Name())) + } + } + } + + tm.templateFiles = templates + tm.valuesFiles = values + + return nil +} + +// GetTemplateFiles возвращает список файлов шаблонов +func (tm *TemplateManager) GetTemplateFiles() []string { + return tm.templateFiles +} + +// GetValuesFiles возвращает список файлов значений +func (tm *TemplateManager) GetValuesFiles() []string { + return tm.valuesFiles +} + +// RenderTemplates рендерит выбранные шаблоны +func (tm *TemplateManager) RenderTemplates(ctx context.Context, selectedTemplates []string, customValues map[string]string) (map[string]string, error) { + // Настраиваем опции для рендеринга + opts := engine.Options{ + Root: tm.rootDir, + Offline: true, // Работаем в offline режиме для интерактивного режима + Debug: false, + Full: true, + TemplateFiles: selectedTemplates, + } + + // Добавляем кастомные значения + for key, value := range customValues { + opts.Values = append(opts.Values, fmt.Sprintf("%s=%s", key, value)) + } + + // Добавляем файлы значений + opts.ValueFiles = tm.valuesFiles + + // Выполняем рендеринг + result, err := engine.Render(ctx, nil, opts) + if err != nil { + return nil, fmt.Errorf("ошибка рендеринга шаблонов: %v", err) + } + + // Конвертируем []byte в map[string]string + resultMap := make(map[string]string) + for key, value := range result { + resultMap[string(key)] = string(value) + } + + return resultMap, nil +} + +// SaveRenderedTemplates сохраняет отрендеренные шаблоны в файлы +func (tm *TemplateManager) SaveRenderedTemplates(result map[string]string, outputDir string) error { + if outputDir == "" { + outputDir = filepath.Join(tm.rootDir, "rendered") + } + + // Создаем выходную директорию + if err := os.MkdirAll(outputDir, 0755); err != nil { + return fmt.Errorf("не удалось создать выходную директорию: %v", err) + } + + for templatePath, content := range result { + // Определяем имя выходного файла + outputFile := filepath.Base(templatePath) + if strings.Contains(templatePath, "templates/") { + // Извлекаем относительный путь от templates/ + relPath := strings.TrimPrefix(templatePath, "templates/") + outputFile = relPath + } + + outputPath := filepath.Join(outputDir, outputFile) + + // Сохраняем файл + if err := ioutil.WriteFile(outputPath, []byte(content), 0644); err != nil { + return fmt.Errorf("не удалось сохранить файл %s: %v", outputPath, err) + } + + tm.outputFiles = append(tm.outputFiles, outputPath) + } + + return nil +} + +// GenerateModeline генерирует modeline для выбранных шаблонов +func (tm *TemplateManager) GenerateModeline(selectedTemplates []string, nodes, endpoints []string) (string, error) { + return modeline.GenerateModeline(nodes, endpoints, selectedTemplates) +} + +// LoadModelineFromFile загружает modeline из файла +func (tm *TemplateManager) LoadModelineFromFile(filename string) (*modeline.Config, error) { + return modeline.ReadAndParseModeline(filename) +} + +// GetPresetTemplates возвращает шаблоны для заданного пресета +func (tm *TemplateManager) GetPresetTemplates(preset string) []string { + var presetTemplates []string + + switch preset { + case "generic": + presetTemplates = []string{ + "generic/templates/controlplane.yaml", + "generic/templates/worker.yaml", + } + case "cozystack": + presetTemplates = []string{ + "cozystack/templates/controlplane.yaml", + "cozystack/templates/worker.yaml", + } + default: + presetTemplates = tm.templateFiles + } + + return presetTemplates +} + +// GetOutputFiles возвращает список выходных файлов +func (tm *TemplateManager) GetOutputFiles() []string { + return tm.outputFiles +} + +// ClearOutputFiles очищает список выходных файлов +func (tm *TemplateManager) ClearOutputFiles() { + tm.outputFiles = []string{} +} \ No newline at end of file diff --git a/internal/pkg/interactive/wizard.go b/internal/pkg/interactive/wizard.go new file mode 100644 index 0000000..4b4c243 --- /dev/null +++ b/internal/pkg/interactive/wizard.go @@ -0,0 +1,562 @@ +package interactive + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/cozystack/talm/internal/pkg/ui/initwizard" +) + +// Wizard основной интерфейс интерактивного мастера +type Wizard struct { + app *tview.Application + pages *tview.Pages + rootDir string + initWizard initwizard.Wizard + nodeManager *NodeManager + templateManager *TemplateManager +} + +// NewWizard создает новый экземпляр интерактивного мастера +func NewWizard(rootDir string) *Wizard { + app := tview.NewApplication() + pages := tview.NewPages() + + // Создаем основной init wizard + initWizard := initwizard.NewInitWizard(rootDir) + + // Создаем менеджер узлов + nodeManager := NewNodeManager(rootDir) + + // Создаем менеджер шаблонов + templateManager := NewTemplateManager(rootDir) + + return &Wizard{ + app: app, + pages: pages, + rootDir: rootDir, + initWizard: initWizard, + nodeManager: nodeManager, + templateManager: templateManager, + } +} + +// Run запускает интерактивный мастер +func (w *Wizard) Run() error { + // Настраиваем обработчики клавиш + w.setupInputCapture() + + // Создаем главное меню + w.showMainMenu() + + // Запускаем приложение + return w.app.Run() +} + +// setupInputCapture настраивает обработку ввода +func (w *Wizard) setupInputCapture() { + w.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyCtrlC, tcell.KeyEscape: + // Если мы на главной странице, выходим + if w.pages.GetPageCount() == 1 { + w.app.Stop() + return nil + } + // Иначе возвращаемся назад + w.pages.HidePage("main") + w.pages.ShowPage("menu") + return nil + case tcell.KeyCtrlQ: + w.app.Stop() + return nil + } + return event + }) +} + +// showMainMenu показывает главное меню +func (w *Wizard) showMainMenu() { + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 3, 0, false). + AddItem(w.createMainMenu(), 0, 1, true). + AddItem(tview.NewBox(), 3, 0, false) + + w.pages.AddAndSwitchToPage("menu", flex, true) +} + +// createMainMenu создает главное меню +func (w *Wizard) createMainMenu() *tview.Flex { + menu := tview.NewGrid(). + SetColumns(0, 40, 0). + SetRows(0, 3, 1, 3, 1, 3, 1, 3, 1, 3, 0) + + title := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetText("[yellow]TALM - Интерактивный режим[-]") + + menu.AddItem(title, 1, 1, 1, 1, 0, 0, false) + + // Проверяем, существует ли проект + isProjectExists := w.checkProjectExists() + + // Кнопки меню + buttons := []struct { + text string + row int + action func() + disabled bool + }{ + {"1. Инициализация проекта (talm init)", 2, w.startInitWizard, false}, + {"2. Получение информации об узлах", 4, w.showNodesInfo, !isProjectExists}, + {"3. Генерация шаблонов (talm template)", 6, w.showTemplateWizard, !isProjectExists}, + {"4. Выход", 8, func() { w.app.Stop() }, false}, + } + + for i, btn := range buttons { + button := tview.NewButton(btn.text). + SetSelectedFunc(btn.action) + + if btn.disabled { + button.SetDisabled(true) + } + + menu.AddItem(button, btn.row, 1, 1, 1, 0, 0, i == 0) + } + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(menu, 0, 1, true) + + return flex +} + +// checkProjectExists проверяет, существует ли проект +func (w *Wizard) checkProjectExists() bool { + chartFile := filepath.Join(w.rootDir, "Chart.yaml") + _, err := os.Stat(chartFile) + return err == nil +} + +// startInitWizard запускает мастер инициализации +func (w *Wizard) startInitWizard() { + w.pages.HidePage("menu") + + // Запускаем init wizard + go func() { + if err := w.initWizard.Run(); err != nil { + w.showErrorModal(fmt.Sprintf("Ошибка инициализации: %v", err)) + } else { + w.showSuccessModal("Проект успешно инициализирован!") + } + w.pages.ShowPage("menu") + }() +} + +// showNodesInfo показывает информацию об узлах +func (w *Wizard) showNodesInfo() { + w.pages.HidePage("menu") + + // Создаем страницу информации об узлах + w.createNodesInfoPage() + + // Загружаем информацию об узлах + go w.loadNodesInfo() +} + +// createNodesInfoPage создает страницу информации об узлах +func (w *Wizard) createNodesInfoPage() { + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 1, 0, false). + AddItem(w.createNodesInfoContent(), 0, 1, true). + AddItem(tview.NewBox(), 1, 0, false) + + w.pages.AddAndSwitchToPage("nodes_info", flex, true) +} + +// createNodesInfoContent создает содержимое страницы информации об узлах +func (w *Wizard) createNodesInfoContent() *tview.Flex { + content := tview.NewGrid(). + SetColumns(0, 60, 0). + SetRows(0, 3, 1, 3, 1, 3, 0) + + title := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetText("[yellow]Информация об узлах[-]") + + content.AddItem(title, 1, 1, 1, 1, 0, 0, false) + + // Список команд для получения информации + commands := []string{ + "version - Версия Talos", + "list - Список файлов", + "memory - Информация о памяти", + "processes - Список процессов", + "mounts - Список монтирований", + "disks - Информация о дисках", + "netstat - Сетевые соединения", + "health - Состояние кластера", + "support - Поддержка и отладка", + } + + infoText := "Выберите команду для получения информации об узлах:\n\n" + for i, cmd := range commands { + infoText += fmt.Sprintf("%d. %s\n", i+1, cmd) + } + + infoView := tview.NewTextView(). + SetText(infoText). + SetScrollable(true) + + content.AddItem(infoView, 2, 1, 1, 1, 0, 0, false) + + // Кнопки навигации + backButton := tview.NewButton("Назад"). + SetSelectedFunc(func() { + w.pages.HidePage("nodes_info") + w.pages.ShowPage("menu") + }) + + content.AddItem(backButton, 4, 1, 1, 1, 0, 0, false) + + box := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(content, 0, 1, true) + + return box +} + +// loadNodesInfo загружает информацию об узлах +func (w *Wizard) loadNodesInfo() { + // Загружаем узлы из конфигурации + if err := w.nodeManager.LoadNodes(); err != nil { + w.pages.HidePage("nodes_info") + w.showErrorModal(fmt.Sprintf("Ошибка загрузки узлов: %v", err)) + w.pages.ShowPage("menu") + return + } + + // Обновляем страницу с информацией об узлах + w.updateNodesInfoPage() + + // Показываем обновленную страницу + w.pages.ShowPage("nodes_info") +} + +// updateNodesInfoPage обновляет страницу информации об узлах +func (w *Wizard) updateNodesInfoPage() { + nodes := w.nodeManager.GetNodes() + + // Обновляем контент страницы + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 1, 0, false). + AddItem(w.createNodesInfoContentWithNodes(nodes), 0, 1, true). + AddItem(tview.NewBox(), 1, 0, false) + + w.pages.AddAndSwitchToPage("nodes_info", flex, true) +} + +// createNodesInfoContentWithNodes создает содержимое страницы с информацией об узлах +func (w *Wizard) createNodesInfoContentWithNodes(nodes []NodeInfo) *tview.Flex { + content := tview.NewGrid(). + SetColumns(0, 60, 0). + SetRows(0, 3, 1, 3, 1, 3, 0) + + title := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetText("[yellow]Информация об узлах[-]") + + content.AddItem(title, 1, 1, 1, 1, 0, 0, false) + + // Список узлов + if len(nodes) == 0 { + noNodesText := tview.NewTextView(). + SetText("Узлы не найдены.\n\nВозможные причины:\n• Проект не инициализирован\n• Нет файлов конфигурации узлов\n• Ошибка чтения конфигурации"). + SetTextAlign(tview.AlignCenter) + + content.AddItem(noNodesText, 2, 1, 1, 1, 0, 0, false) + } else { + // Отображаем список узлов + nodesText := "Найденные узлы:\n\n" + for i, node := range nodes { + nodesText += fmt.Sprintf("%d. %s\n", i+1, node.Hostname) + if node.IP != "" { + nodesText += fmt.Sprintf(" IP: %s\n", node.IP) + } + nodesText += fmt.Sprintf(" Статус: %s\n\n", node.Status) + } + + nodesView := tview.NewTextView(). + SetText(nodesText). + SetScrollable(true) + + content.AddItem(nodesView, 2, 1, 1, 1, 0, 0, false) + } + + // Команды для работы с узлами + commandsText := "\nДоступные команды:\n" + commands := []string{ + "version - Версия Talos", + "list - Список файлов", + "memory - Информация о памяти", + "processes - Список процессов", + "mounts - Список монтирований", + "disks - Информация о дисках", + "health - Состояние кластера", + } + + for i, cmd := range commands { + commandsText += fmt.Sprintf("%d. %s\n", i+1, cmd) + } + + commandsView := tview.NewTextView(). + SetText(commandsText). + SetScrollable(true) + + content.AddItem(commandsView, 4, 1, 1, 1, 0, 0, false) + + // Кнопки навигации + backButton := tview.NewButton("Назад"). + SetSelectedFunc(func() { + w.pages.HidePage("nodes_info") + w.pages.ShowPage("menu") + }) + + content.AddItem(backButton, 6, 1, 1, 1, 0, 0, false) + + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(content, 0, 1, true) + + return flex +} + +// showTemplateWizard показывает мастер шаблонов +func (w *Wizard) showTemplateWizard() { + w.pages.HidePage("menu") + + // Обнаруживаем доступные шаблоны + if err := w.templateManager.DiscoverTemplates(); err != nil { + w.showErrorModal(fmt.Sprintf("Ошибка обнаружения шаблонов: %v", err)) + w.pages.ShowPage("menu") + return + } + + // Создаем страницу мастера шаблонов + w.createTemplatePage() +} + +// createTemplatePage создает страницу мастера шаблонов +func (w *Wizard) createTemplatePage() { + flex := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(tview.NewBox(), 1, 0, false). + AddItem(w.createTemplateContent(), 0, 1, true). + AddItem(tview.NewBox(), 1, 0, false) + + w.pages.AddAndSwitchToPage("template", flex, true) +} + +// createTemplateContent создает содержимое страницы шаблонов +func (w *Wizard) createTemplateContent() *tview.Flex { + content := tview.NewGrid(). + SetColumns(0, 60, 0). + SetRows(0, 3, 1, 3, 1, 3, 0) + + title := tview.NewTextView(). + SetTextAlign(tview.AlignCenter). + SetText("[yellow]Генерация шаблонов (talm template)[-]") + + content.AddItem(title, 1, 1, 1, 1, 0, 0, false) + + // Получаем доступные шаблоны + templates := w.templateManager.GetTemplateFiles() + values := w.templateManager.GetValuesFiles() + + infoText := "Мастер генерации шаблонов\n\n" + + if len(templates) == 0 { + infoText += "[red]Шаблоны не найдены![-]\n\n" + infoText += "Возможные причины:\n" + infoText += "• Нет директории templates/\n" + infoText += "• Нет файлов шаблонов\n" + infoText += "• Ошибка сканирования директорий\n\n" + } else { + infoText += fmt.Sprintf("Найдено шаблонов: %d\n", len(templates)) + for i, template := range templates { + infoText += fmt.Sprintf("%d. %s\n", i+1, template) + } + infoText += "\n" + } + + if len(values) > 0 { + infoText += fmt.Sprintf("Найдено файлов значений: %d\n", len(values)) + for i, value := range values { + infoText += fmt.Sprintf("%d. %s\n", i+1, value) + } + } else { + infoText += "Файлы значений не найдены\n" + } + + infoText += "\n\nДоступные функции:\n" + infoText += "1. Выбор шаблонов для рендеринга\n" + infoText += "2. Настройка параметров\n" + infoText += "3. Генерация конфигураций\n" + infoText += "4. Просмотр результатов\n" + infoText += "5. Сохранение в файлы\n" + + infoView := tview.NewTextView(). + SetText(infoText). + SetScrollable(true) + + content.AddItem(infoView, 2, 1, 1, 1, 0, 0, false) + + // Кнопки действий + buttons := tview.NewFlex().SetDirection(tview.FlexRow) + + if len(templates) > 0 { + renderButton := tview.NewButton("1. Рендерить шаблоны"). + SetSelectedFunc(w.renderTemplates) + buttons.AddItem(renderButton, 1, 0, true) + } + + showConfigButton := tview.NewButton("2. Показать конфигурацию"). + SetSelectedFunc(w.showTemplateConfig) + buttons.AddItem(showConfigButton, 1, 0, true) + + // Кнопки навигации + backButton := tview.NewButton("Назад"). + SetSelectedFunc(func() { + w.pages.HidePage("template") + w.pages.ShowPage("menu") + }) + buttons.AddItem(backButton, 1, 0, true) + + content.AddItem(buttons, 4, 1, 1, 1, 0, 0, false) + + box := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(content, 0, 1, true) + + return box +} + +// renderTemplates выполняет рендеринг шаблонов +func (w *Wizard) renderTemplates() { + w.pages.HidePage("template") + + go func() { + ctx := context.Background() + templates := w.templateManager.GetTemplateFiles() + + // Если есть шаблоны, рендерим их + if len(templates) == 0 { + w.showErrorModal("Нет шаблонов для рендеринга") + w.pages.ShowPage("template") + return + } + + // Рендерим все шаблоны + result, err := w.templateManager.RenderTemplates(ctx, templates, nil) + if err != nil { + w.showErrorModal(fmt.Sprintf("Ошибка рендеринга: %v", err)) + w.pages.ShowPage("template") + return + } + + // Сохраняем результаты + if err := w.templateManager.SaveRenderedTemplates(result, ""); err != nil { + w.showErrorModal(fmt.Sprintf("Ошибка сохранения: %v", err)) + w.pages.ShowPage("template") + return + } + + outputFiles := w.templateManager.GetOutputFiles() + successMsg := fmt.Sprintf("Шаблоны успешно отрендерены!\n\nСохранено файлов: %d\n\n", len(outputFiles)) + for i, file := range outputFiles { + successMsg += fmt.Sprintf("%d. %s\n", i+1, file) + } + + w.showSuccessModal(successMsg) + w.pages.ShowPage("template") + }() +} + +// showTemplateConfig показывает конфигурацию шаблонов +func (w *Wizard) showTemplateConfig() { + // Показываем диалог с конфигурацией + infoText := "Конфигурация шаблонов:\n\n" + + // Показываем доступные шаблоны + templates := w.templateManager.GetTemplateFiles() + infoText += fmt.Sprintf("Шаблоны (%d):\n", len(templates)) + for i, template := range templates { + infoText += fmt.Sprintf("%d. %s\n", i+1, template) + } + infoText += "\n" + + // Показываем файлы значений + values := w.templateManager.GetValuesFiles() + infoText += fmt.Sprintf("Файлы значений (%d):\n", len(values)) + for i, value := range values { + infoText += fmt.Sprintf("%d. %s\n", i+1, value) + } + infoText += "\n" + + infoText += "Параметры рендеринга:\n" + infoText += "• Offline режим: включен\n" + infoText += "• Выходная директория: rendered/\n" + infoText += "• Полный рендеринг: включен\n" + + w.showInfoModal(infoText) +} + +// showErrorModal показывает модальное окно с ошибкой +func (w *Wizard) showErrorModal(message string) { + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + w.pages.HidePage("error") + w.pages.ShowPage("menu") + }) + + w.pages.AddAndSwitchToPage("error", modal, false) + w.pages.ShowPage("error") +} + +// showSuccessModal показывает модальное окно с успехом +func (w *Wizard) showSuccessModal(message string) { + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + w.pages.HidePage("success") + w.pages.ShowPage("menu") + }) + + w.pages.AddAndSwitchToPage("success", modal, false) + w.pages.ShowPage("success") +} + +// showInfoModal показывает информационное модальное окно +func (w *Wizard) showInfoModal(message string) { + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + w.pages.HidePage("info") + w.pages.ShowPage("menu") + }) + + w.pages.AddAndSwitchToPage("info", modal, false) + w.pages.ShowPage("info") +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/cache.go b/internal/pkg/ui/initwizard/cache.go new file mode 100644 index 0000000..dbb9922 --- /dev/null +++ b/internal/pkg/ui/initwizard/cache.go @@ -0,0 +1,245 @@ +package initwizard + +import ( + "sync" + "time" +) + +// CacheEntry represents a cache entry +type CacheEntry struct { + Value interface{} + ExpiresAt time.Time + CreatedAt time.Time +} + +// Cache represents a simple in-memory cache with TTL +type Cache struct { + data map[string]*CacheEntry + mutex sync.RWMutex + ttl time.Duration + evicted int64 + hits int64 + misses int64 +} + +// NewCache creates a new cache instance +func NewCache(ttl time.Duration) *Cache { + return &Cache{ + data: make(map[string]*CacheEntry), + ttl: ttl, + } +} + +// Get retrieves a value from the cache +func (c *Cache) Get(key string) (interface{}, bool) { + c.mutex.RLock() + entry, exists := c.data[key] + c.mutex.RUnlock() + + if !exists { + c.misses++ + return nil, false + } + + // Check TTL + if time.Now().After(entry.ExpiresAt) { + c.mutex.Lock() + delete(c.data, key) + c.mutex.Unlock() + c.evicted++ + c.misses++ + return nil, false + } + + c.hits++ + return entry.Value, true +} + +// Set sets a value in the cache +func (c *Cache) Set(key string, value interface{}) { + c.mutex.Lock() + defer c.mutex.Unlock() + + c.data[key] = &CacheEntry{ + Value: value, + ExpiresAt: time.Now().Add(c.ttl), + CreatedAt: time.Now(), + } +} + +// Delete removes an entry from the cache +func (c *Cache) Delete(key string) { + c.mutex.Lock() + defer c.mutex.Unlock() + delete(c.data, key) +} + +// Clear clears the entire cache +func (c *Cache) Clear() { + c.mutex.Lock() + defer c.mutex.Unlock() + c.data = make(map[string]*CacheEntry) +} + +// Size returns the cache size +func (c *Cache) Size() int { + c.mutex.RLock() + defer c.mutex.RUnlock() + return len(c.data) +} + +// Stats returns cache statistics +func (c *Cache) Stats() (hits, misses, evicted, size int64) { + c.mutex.RLock() + defer c.mutex.RUnlock() + return c.hits, c.misses, c.evicted, int64(len(c.data)) +} + +// Cleanup removes expired entries +func (c *Cache) Cleanup() { + c.mutex.Lock() + defer c.mutex.Unlock() + + now := time.Now() + for key, entry := range c.data { + if now.After(entry.ExpiresAt) { + delete(c.data, key) + c.evicted++ + } + } +} + +// StartCleanup starts automatic cache cleanup +func (c *Cache) StartCleanup(interval time.Duration) { + ticker := time.NewTicker(interval) + go func() { + defer ticker.Stop() + for { + select { + case <-ticker.C: + c.Cleanup() + } + } + }() +} + +// NodeCache specialized cache for node information +type NodeCache struct { + cache *Cache + nodeMutex sync.RWMutex + nodes map[string]NodeInfo // Cache of the latest node information +} + +// NewNodeCache creates a new node cache +func NewNodeCache(ttl time.Duration) *NodeCache { + return &NodeCache{ + cache: NewCache(ttl), + nodes: make(map[string]NodeInfo), + } +} + +// GetNodeInfo retrieves node information from the cache +func (nc *NodeCache) GetNodeInfo(ip string) (NodeInfo, bool) { + // First check the in-memory cache + nc.nodeMutex.RLock() + node, exists := nc.nodes[ip] + nc.nodeMutex.RUnlock() + + if exists { + return node, true + } + + // Check the general cache + if cached, found := nc.cache.Get("node:" + ip); found { + if nodeInfo, ok := cached.(NodeInfo); ok { + // Save to local cache + nc.nodeMutex.Lock() + nc.nodes[ip] = nodeInfo + nc.nodeMutex.Unlock() + return nodeInfo, true + } + } + + return NodeInfo{}, false +} + +// SetNodeInfo saves node information to the cache +func (nc *NodeCache) SetNodeInfo(ip string, node NodeInfo) { + nc.nodeMutex.Lock() + nc.nodes[ip] = node + nc.nodeMutex.Unlock() + + nc.cache.Set("node:"+ip, node) +} + +// InvalidateNodeInfo removes node information from the cache +func (nc *NodeCache) InvalidateNodeInfo(ip string) { + nc.nodeMutex.Lock() + delete(nc.nodes, ip) + nc.nodeMutex.Unlock() + + nc.cache.Delete("node:" + ip) +} + +// HardwareCache specialized cache for hardware information +type HardwareCache struct { + cache *Cache +} + +// NewHardwareCache creates a new hardware cache +func NewHardwareCache(ttl time.Duration) *HardwareCache { + return &HardwareCache{ + cache: NewCache(ttl), + } +} + +// GetHardwareInfo retrieves hardware information from the cache +func (hc *HardwareCache) GetHardwareInfo(ip string) (Hardware, bool) { + if cached, found := hc.cache.Get("hardware:" + ip); found { + if hardware, ok := cached.(Hardware); ok { + return hardware, true + } + } + return Hardware{}, false +} + +// SetHardwareInfo saves hardware information to the cache +func (hc *HardwareCache) SetHardwareInfo(ip string, hardware Hardware) { + hc.cache.Set("hardware:"+ip, hardware) +} + +// InvalidateHardwareInfo removes hardware information from the cache +func (hc *HardwareCache) InvalidateHardwareInfo(ip string) { + hc.cache.Delete("hardware:" + ip) +} + +// ConfigCache cache for configuration files +type ConfigCache struct { + cache *Cache +} + +// NewConfigCache creates a new configuration cache +func NewConfigCache(ttl time.Duration) *ConfigCache { + return &ConfigCache{ + cache: NewCache(ttl), + } +} + +// GetConfig retrieves configuration from the cache +func (cc *ConfigCache) GetConfig(clusterName, configType string) (interface{}, bool) { + key := clusterName + ":" + configType + return cc.cache.Get(key) +} + +// SetConfig saves configuration to the cache +func (cc *ConfigCache) SetConfig(clusterName, configType string, config interface{}) { + key := clusterName + ":" + configType + cc.cache.Set(key, config) +} + +// InvalidateClusterConfig removes all cluster configurations from the cache +func (cc *ConfigCache) InvalidateClusterConfig(clusterName string) { + // Simplified implementation - in a real application, prefixes can be used + // для более эффективного удаления + cc.cache.Clear() +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/debug.log b/internal/pkg/ui/initwizard/debug.log new file mode 100644 index 0000000..1f3b67a --- /dev/null +++ b/internal/pkg/ui/initwizard/debug.log @@ -0,0 +1,2 @@ +DEBUG: 2025/12/21 22:57:20 Запуск мастера инициализации +DEBUG: 2025/12/21 22:57:20 Проверка существующих файлов: false diff --git a/internal/pkg/ui/initwizard/errors.go b/internal/pkg/ui/initwizard/errors.go new file mode 100644 index 0000000..be17751 --- /dev/null +++ b/internal/pkg/ui/initwizard/errors.go @@ -0,0 +1,250 @@ +package initwizard + +import ( + "fmt" + "strings" +) + +// ErrorType определяет тип ошибки для программной обработки +type ErrorType int + +const ( + // Ошибки валидации + ErrValidation ErrorType = iota + 1000 + // Ошибки сети + ErrNetwork + // Ошибки файловой системы + ErrFilesystem + // Ошибки конфигурации + ErrConfiguration + // Ошибки генерации + ErrGeneration + // Ошибки сканирования + ErrScanning + // Ошибки обработки данных + ErrDataProcessing + // Ошибки UI + ErrUI + // Внутренние ошибки + ErrInternal +) + +// AppError представляет ошибку с контекстом и кодом +type AppError struct { + Type ErrorType + Code string + Message string + Details string + Original error + Location string + StackTrace []string +} + +// NewError создает новую ошибку приложения +func NewError(errorType ErrorType, code, message, details string) *AppError { + return &AppError{ + Type: errorType, + Code: code, + Message: message, + Details: details, + Location: getCallerLocation(), + } +} + +// NewErrorWithCause создает новую ошибку с исходной причиной +func NewErrorWithCause(errorType ErrorType, code, message, details string, original error) *AppError { + err := NewError(errorType, code, message, details) + err.Original = original + return err +} + +// Error реализует интерфейс error +func (e *AppError) Error() string { + var result strings.Builder + + // Базовое сообщение + result.WriteString(fmt.Sprintf("[%s] %s", e.Code, e.Message)) + + // Детали если есть + if e.Details != "" { + result.WriteString(fmt.Sprintf(": %s", e.Details)) + } + + // Местоположение + if e.Location != "" { + result.WriteString(fmt.Sprintf(" (location: %s)", e.Location)) + } + + // Исходная ошибка если есть + if e.Original != nil { + result.WriteString(fmt.Sprintf("; caused by: %v", e.Original)) + } + + return result.String() +} + +// Unwrap возвращает исходную ошибку для error wrapping +func (e *AppError) Unwrap() error { + return e.Original +} + +// Is проверяет тип ошибки +func (e *AppError) Is(target error) bool { + if appErr, ok := target.(*AppError); ok { + return e.Type == appErr.Type || e.Code == appErr.Code + } + return false +} + +// IsType проверяет тип ошибки +func (e *AppError) IsType(errorType ErrorType) bool { + return e.Type == errorType +} + +// IsCode проверяет код ошибки +func (e *AppError) IsCode(code string) bool { + return e.Code == code +} + +// WithDetails добавляет дополнительные детали к ошибке +func (e *AppError) WithDetails(details string) *AppError { + e.Details = details + return e +} + +// WithLocation устанавливает местоположение ошибки +func (e *AppError) WithLocation(location string) *AppError { + e.Location = location + return e +} + +// Helper функции для создания типизированных ошибок + +// NewValidationError создает ошибку валидации +func NewValidationError(code, message, details string) *AppError { + return NewError(ErrValidation, code, message, details) +} + +// NewValidationErrorWithCause создает ошибку валидации с причиной +func NewValidationErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrValidation, code, message, details, original) +} + +// NewNetworkError создает ошибку сети +func NewNetworkError(code, message, details string) *AppError { + return NewError(ErrNetwork, code, message, details) +} + +// NewNetworkErrorWithCause создает ошибку сети с причиной +func NewNetworkErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrNetwork, code, message, details, original) +} + +// NewFilesystemError создает ошибку файловой системы +func NewFilesystemError(code, message, details string) *AppError { + return NewError(ErrFilesystem, code, message, details) +} + +// NewFilesystemErrorWithCause создает ошибку файловой системы с причиной +func NewFilesystemErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrFilesystem, code, message, details, original) +} + +// NewConfigurationError создает ошибку конфигурации +func NewConfigurationError(code, message, details string) *AppError { + return NewError(ErrConfiguration, code, message, details) +} + +// NewConfigurationErrorWithCause создает ошибку конфигурации с причиной +func NewConfigurationErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrConfiguration, code, message, details, original) +} + +// NewGenerationError создает ошибку генерации +func NewGenerationError(code, message, details string) *AppError { + return NewError(ErrGeneration, code, message, details) +} + +// NewGenerationErrorWithCause создает ошибку генерации с причиной +func NewGenerationErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrGeneration, code, message, details, original) +} + +// NewScanningError создает ошибку сканирования +func NewScanningError(code, message, details string) *AppError { + return NewError(ErrScanning, code, message, details) +} + +// NewScanningErrorWithCause создает ошибку сканирования с причиной +func NewScanningErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrScanning, code, message, details, original) +} + +// NewDataProcessingError создает ошибку обработки данных +func NewDataProcessingError(code, message, details string) *AppError { + return NewError(ErrDataProcessing, code, message, details) +} + +// NewDataProcessingErrorWithCause создает ошибку обработки данных с причиной +func NewDataProcessingErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrDataProcessing, code, message, details, original) +} + +// NewUIError создает ошибку UI +func NewUIError(code, message, details string) *AppError { + return NewError(ErrUI, code, message, details) +} + +// NewUIErrorWithCause создает ошибку UI с причиной +func NewUIErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrUI, code, message, details, original) +} + +// NewInternalError создает внутреннюю ошибку +func NewInternalError(code, message, details string) *AppError { + return NewError(ErrInternal, code, message, details) +} + +// NewInternalErrorWithCause создает внутреннюю ошибку с причиной +func NewInternalErrorWithCause(code, message, details string, original error) *AppError { + return NewErrorWithCause(ErrInternal, code, message, details, original) +} + +// getCallerLocation возвращает местоположение вызова функции +func getCallerLocation() string { + // Упрощенная версия для получения местоположения + // В реальном приложении можно использовать более продвинутые методы + return "initwizard" +} + +// WrapError оборачивает существующую ошибку в AppError +func WrapError(err error, errorType ErrorType, code, message, details string) *AppError { + if appErr, ok := err.(*AppError); ok { + return appErr + } + return NewErrorWithCause(errorType, code, message, details, err) +} + +// IsValidationError проверяет, является ли ошибка ошибкой валидации +func IsValidationError(err error) bool { + if appErr, ok := err.(*AppError); ok { + return appErr.IsType(ErrValidation) + } + return false +} + +// IsNetworkError проверяет, является ли ошибка ошибкой сети +func IsNetworkError(err error) bool { + if appErr, ok := err.(*AppError); ok { + return appErr.IsType(ErrNetwork) + } + return false +} + +// IsFilesystemError проверяет, является ли ошибка ошибкой файловой системы +func IsFilesystemError(err error) bool { + if appErr, ok := err.(*AppError); ok { + return appErr.IsType(ErrFilesystem) + } + return false +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/factory.go b/internal/pkg/ui/initwizard/factory.go new file mode 100644 index 0000000..f4262dd --- /dev/null +++ b/internal/pkg/ui/initwizard/factory.go @@ -0,0 +1,629 @@ +package initwizard + +import ( + "context" + "fmt" + "time" + + "github.com/siderolabs/talos/pkg/machinery/config/generate/secrets" +) + +// WizardConfig конфигурация мастера инициализации +type WizardConfig struct { + // Основные настройки + ClusterName string + Preset string + TalosVersion string + + // Настройки сети + NetworkToScan string + ScanTimeout time.Duration + ScanWorkers int + + // Настройки кэширования + CacheTTL time.Duration + EnableNodeCache bool + EnableHardwareCache bool + EnableConfigCache bool + + // Настройки производительности + MaxWorkers int + RequestTimeout time.Duration + EnableRateLimiting bool + RateLimit int // запросов в секунду + + // Настройки логирования + LogLevel string + LogFile string + EnableDebug bool + + // Настройки UI + UITheme string + EnableAnimations bool + + // Настройки безопасности + SkipCertVerification bool + TLSTimeout time.Duration +} + +// DefaultConfig возвращает конфигурацию по умолчанию +func DefaultConfig() *WizardConfig { + return &WizardConfig{ + ClusterName: "mycluster", + Preset: "generic", + TalosVersion: "v1.7.0", + NetworkToScan: "192.168.1.0/24", + ScanTimeout: 30 * time.Second, + ScanWorkers: 10, + CacheTTL: 5 * time.Minute, + EnableNodeCache: true, + EnableHardwareCache: true, + EnableConfigCache: true, + MaxWorkers: 10, + RequestTimeout: 10 * time.Second, + EnableRateLimiting: true, + RateLimit: 5, + LogLevel: "info", + EnableDebug: false, + UITheme: "default", + EnableAnimations: true, + SkipCertVerification: false, + TLSTimeout: 5 * time.Second, + } +} + +// BuilderPattern паттерн для создания мастера инициализации +type WizardBuilder struct { + config *WizardConfig +} + +// NewWizardBuilder создает новый builder +func NewWizardBuilder() *WizardBuilder { + return &WizardBuilder{ + config: DefaultConfig(), + } +} + +// WithClusterName устанавливает имя кластера +func (wb *WizardBuilder) WithClusterName(name string) *WizardBuilder { + wb.config.ClusterName = name + return wb +} + +// WithPreset устанавливает пресет +func (wb *WizardBuilder) WithPreset(preset string) *WizardBuilder { + wb.config.Preset = preset + return wb +} + +// WithTalosVersion устанавливает версию Talos +func (wb *WizardBuilder) WithTalosVersion(version string) *WizardBuilder { + wb.config.TalosVersion = version + return wb +} + +// WithNetworkToScan устанавливает сеть для сканирования +func (wb *WizardBuilder) WithNetworkToScan(cidr string) *WizardBuilder { + wb.config.NetworkToScan = cidr + return wb +} + +// WithCacheSettings настраивает кэширование +func (wb *WizardBuilder) WithCacheSettings(ttl time.Duration, nodeCache, hardwareCache, configCache bool) *WizardBuilder { + wb.config.CacheTTL = ttl + wb.config.EnableNodeCache = nodeCache + wb.config.EnableHardwareCache = hardwareCache + wb.config.EnableConfigCache = configCache + return wb +} + +// WithPerformanceSettings настраивает производительность +func (wb *WizardBuilder) WithPerformanceSettings(maxWorkers int, requestTimeout time.Duration) *WizardBuilder { + wb.config.MaxWorkers = maxWorkers + wb.config.RequestTimeout = requestTimeout + return wb +} + +// WithNetworkSettings настраивает сетевые параметры +func (wb *WizardBuilder) WithNetworkSettings(timeout time.Duration, workers int) *WizardBuilder { + wb.config.ScanTimeout = timeout + wb.config.ScanWorkers = workers + return wb +} + +// WithRateLimiting включает ограничение скорости +func (wb *WizardBuilder) WithRateLimiting(enabled bool, rateLimit int) *WizardBuilder { + wb.config.EnableRateLimiting = enabled + wb.config.RateLimit = rateLimit + return wb +} + +// WithLogging настраивает логирование +func (wb *WizardBuilder) WithLogging(level, logFile string, debug bool) *WizardBuilder { + wb.config.LogLevel = level + wb.config.LogFile = logFile + wb.config.EnableDebug = debug + return wb +} + +// WithUISettings настраивает UI +func (wb *WizardBuilder) WithUISettings(theme string, animations bool) *WizardBuilder { + wb.config.UITheme = theme + wb.config.EnableAnimations = animations + return wb +} + +// WithSecuritySettings настраивает параметры безопасности +func (wb *WizardBuilder) WithSecuritySettings(skipCert bool, timeout time.Duration) *WizardBuilder { + wb.config.SkipCertVerification = skipCert + wb.config.TLSTimeout = timeout + return wb +} + +// Build создает компоненты на основе конфигурации +func (wb *WizardBuilder) Build() (*WizardComponents, error) { + // Создаем компоненты + components := &WizardComponents{ + config: wb.config, + } + + // Создаем кэши + if wb.config.EnableNodeCache { + components.NodeCache = NewNodeCache(wb.config.CacheTTL) + } + + if wb.config.EnableHardwareCache { + components.HardwareCache = NewHardwareCache(wb.config.CacheTTL) + } + + if wb.config.EnableConfigCache { + components.ConfigCache = NewConfigCache(wb.config.CacheTTL) + } + + // Создаем connection pool + components.ConnectionPool = NewConnectionPool( + wb.config.RequestTimeout, + 5*time.Minute, + ) + + // Создаем сетевой клиент + components.NetworkClient = NewNetworkClient( + components.ConnectionPool, + wb.config.RequestTimeout, + ) + + // Создаем rate limiter + if wb.config.EnableRateLimiting { + components.RateLimiter = NewRateLimiter(wb.config.RateLimit) + } + + // Создаем command executor (пустая реализация для базового сканера) + commandExecutor := &DefaultCommandExecutor{} + + // Создаем основные компоненты + components.Validator = NewValidator() + components.Processor = NewDataProcessor() + components.Generator = NewGenerator() + + // Создаем сканер с commandExecutor + components.Scanner = NewNetworkScanner(commandExecutor) + + // Создаем презентер (будет создан после wizard) + return components, nil +} + +// BuildWizard создает полный wizard с компонентами +func (wb *WizardBuilder) BuildWizard() (*WizardImpl, error) { + components, err := wb.Build() + if err != nil { + return nil, NewInternalErrorWithCause( + "FAC_001", + "не удалось создать компоненты", + "ошибка при создании компонентов wizard", + err, + ) + } + + // Создаем данные инициализации + data := &InitData{ + Preset: wb.config.Preset, + ClusterName: wb.config.ClusterName, + NetworkToScan: wb.config.NetworkToScan, + } + + // UI компоненты будут созданы в презентере + + // Создаем wizard + wizard := &WizardImpl{ + data: data, + app: nil, // Будет создан в презентере + pages: nil, // Будет создан в презентере + validator: components.Validator, + scanner: components.Scanner, + processor: components.Processor, + generator: components.Generator, + + } + + // Создаем презентер + // Создаем презентер с базовыми параметрами + // Презентер будет настроен в презентере + components.Presenter = nil // Временное значение + wizard.presenter = components.Presenter + + return wizard, nil +} + +// WizardComponents содержит все компоненты мастера +type WizardComponents struct { + // Основные компоненты + Validator Validator + Scanner NetworkScanner + Processor DataProcessor + Generator Generator + Presenter Presenter + + // Кэши + NodeCache *NodeCache + HardwareCache *HardwareCache + ConfigCache *ConfigCache + + // Сетевые компоненты + ConnectionPool *ConnectionPool + NetworkClient *NetworkClient + RateLimiter *RateLimiter + + // Конфигурация + config *WizardConfig +} + +// GetConfig возвращает конфигурацию +func (wc *WizardComponents) GetConfig() *WizardConfig { + return wc.config +} + +// StartCaches запускает фоновые процессы кэшей +func (wc *WizardComponents) StartCaches() { + if wc.NodeCache != nil { + go wc.NodeCache.cache.StartCleanup(1 * time.Minute) + } +} + +// StopCaches останавливает кэши +func (wc *WizardComponents) StopCaches() { + // Кэши останавливаются автоматически при очистке +} + +// Close закрывает все ресурсы +func (wc *WizardComponents) Close() error { + if wc.ConnectionPool != nil { + return wc.ConnectionPool.Close() + } + return nil +} + +// GetStats возвращает статистику всех компонентов +func (wc *WizardComponents) GetStats() ComponentStats { + return ComponentStats{ + CacheStats: wc.getCacheStats(), + NetworkStats: wc.getNetworkStats(), + Config: wc.config, + } +} + +func (wc *WizardComponents) getCacheStats() CacheStats { + return CacheStats{ + NodeCache: wc.NodeCache != nil, + HardwareCache: wc.HardwareCache != nil, + ConfigCache: wc.ConfigCache != nil, + CacheTTL: wc.config.CacheTTL, + } +} + +func (wc *WizardComponents) getNetworkStats() NetworkStats { + var poolMetrics PoolMetrics + if wc.ConnectionPool != nil { + poolMetrics = wc.ConnectionPool.GetMetrics() + } + + return NetworkStats{ + PoolSize: wc.ConnectionPool.Size(), + PoolMetrics: poolMetrics, + RateLimiterEnabled: wc.RateLimiter != nil, + MaxWorkers: wc.config.MaxWorkers, + RequestTimeout: wc.config.RequestTimeout, + } +} + +// ComponentStats статистика всех компонентов +type ComponentStats struct { + CacheStats CacheStats + NetworkStats NetworkStats + Config *WizardConfig +} + +// CacheStats статистика кэшей +type CacheStats struct { + NodeCache bool + HardwareCache bool + ConfigCache bool + CacheTTL time.Duration +} + +// NetworkStats статистика сетевых компонентов +type NetworkStats struct { + PoolSize int + PoolMetrics PoolMetrics + RateLimiterEnabled bool + MaxWorkers int + RequestTimeout time.Duration +} + +// Factory фабрика для создания wizard компонентов +type Factory struct { + defaultConfig *WizardConfig +} + +// NewFactory создает новую фабрику +func NewFactory() *Factory { + return &Factory{ + defaultConfig: DefaultConfig(), + } +} + +// CreateDefaultWizard создает wizard с настройками по умолчанию +func (f *Factory) CreateDefaultWizard() (*WizardImpl, error) { + return NewWizardBuilder().BuildWizard() +} + +// CreateWizardWithConfig создает wizard с заданной конфигурацией +func (f *Factory) CreateWizardWithConfig(config *WizardConfig) (*WizardImpl, error) { + builder := NewWizardBuilder() + + // Применяем конфигурацию + if config.ClusterName != "" { + builder.WithClusterName(config.ClusterName) + } + if config.Preset != "" { + builder.WithPreset(config.Preset) + } + if config.TalosVersion != "" { + builder.WithTalosVersion(config.TalosVersion) + } + if config.NetworkToScan != "" { + builder.WithNetworkToScan(config.NetworkToScan) + } + + return builder.BuildWizard() +} + +// CreateMinimalWizard создает минимальный wizard без кэширования +func (f *Factory) CreateMinimalWizard(clusterName, preset string) (*WizardImpl, error) { + return NewWizardBuilder(). + WithClusterName(clusterName). + WithPreset(preset). + WithCacheSettings(0, false, false, false). + BuildWizard() +} + +// ValidateConfig валидирует конфигурацию +func (f *Factory) ValidateConfig(config *WizardConfig) error { + if config == nil { + return NewConfigurationError( + "FAC_002", + "конфигурация не может быть nil", + "необходимо предоставить конфигурацию wizard", + ) + } + + // Валидируем обязательные поля + if config.ClusterName == "" { + return NewValidationError( + "FAC_003", + "имя кластера не может быть пустым", + "поле ClusterName обязательно", + ) + } + + if config.Preset == "" { + return NewValidationError( + "FAC_004", + "пресет не может быть пустым", + "поле Preset обязательно", + ) + } + + // Валидируем пресет + validPresets := []string{"generic", "cozystack"} + validPreset := false + for _, preset := range validPresets { + if config.Preset == preset { + validPreset = true + break + } + } + if !validPreset { + return NewValidationError( + "FAC_005", + "некорректный пресет", + fmt.Sprintf("пресет: %s, допустимые значения: %v", config.Preset, validPresets), + ) + } + + // Валидируем временные интервалы + if config.CacheTTL < 0 { + return NewValidationError( + "FAC_006", + "TTL кэша не может быть отрицательным", + fmt.Sprintf("TTL: %v", config.CacheTTL), + ) + } + + if config.RequestTimeout <= 0 { + return NewValidationError( + "FAC_007", + "таймаут запроса должен быть положительным", + fmt.Sprintf("таймаут: %v", config.RequestTimeout), + ) + } + + return nil +} + +// Application фабрика для создания tview.Application +type Application struct{} + +// NewApplication создает новое приложение +func NewApplication() *Application { + return &Application{} +} + +// CreateApp создает tview.Application с настройками +func (a *Application) CreateApp() interface{} { + // В реальном приложении здесь будет создание tview.Application + // с настройками из конфигурации + return nil +} + +// Pages фабрика для создания tview.Pages +type Pages struct{} + +// NewPages создает новые страницы +func NewPages() *Pages { + return &Pages{} +} + +// CreatePages создает tview.Pages с настройками +func (p *Pages) CreatePages() interface{} { + // В реальном приложении здесь будет создание tview.Pages + // с настройками из конфигурации + return nil +} + +// DefaultCommandExecutor базовая реализация command executor +type DefaultCommandExecutor struct{} + +// ExecuteNodeCommand выполняет команду на узле (базовая реализация) +func (dce *DefaultCommandExecutor) ExecuteNodeCommand(ctx context.Context, nodeIP, command string) (string, error) { + // Базовая реализация через talosctl + // Это заглушка, в реальной реализации здесь будет интеграция с NodeManager + switch command { + case "version": + return fmt.Sprintf("Node: %s\nTalos: v1.7.0\nHostname: %s", nodeIP, nodeIP), nil + case "memory": + return "Общая память: 8192 MiB\nСвободная память: 4096 MiB", nil + case "disks": + return "sda\t\t100 GB\tSATA SSD", nil + case "processes": + return "PID\tИМЯ\tCPU\tПАМЯТЬ\n1\tinit\t0.1\t100MB", nil + default: + return "", fmt.Errorf("команда %s не поддерживается", command) + } +} + +// DefaultGenerator базовая реализация Generator +type DefaultGenerator struct{} + +// NewGenerator создает новый генератор +func NewGenerator() Generator { + return &DefaultGenerator{} +} + +// GenerateChartYAML генерирует Chart.yaml +func (g *DefaultGenerator) GenerateChartYAML(clusterName, preset string) (ChartYAML, error) { + return ChartYAML{ + APIVersion: "v2", + Name: clusterName, + Version: "0.1.0", + Description: fmt.Sprintf("%s cluster chart", preset), + Type: "application", + AppVersion: "1.0", + }, nil +} + +// GenerateValuesYAML генерирует values.yaml +func (g *DefaultGenerator) GenerateValuesYAML(data *InitData) (ValuesYAML, error) { + return ValuesYAML{ + ClusterName: data.ClusterName, + FloatingIP: data.FloatingIP, + KubernetesEndpoint: data.APIServerURL, + EtcdBootstrapped: false, + Preset: data.Preset, + TalosVersion: data.TalosVersion, + APIServerURL: data.APIServerURL, + PodSubnets: data.PodSubnets, + ServiceSubnets: data.ServiceSubnets, + AdvertisedSubnets: data.AdvertisedSubnets, + ClusterDomain: data.ClusterDomain, + Image: data.Image, + OIDCIssuerURL: data.OIDCIssuerURL, + NrHugepages: data.NrHugepages, + Nodes: make(map[string]NodeConfig), + }, nil +} + +// GenerateMachineConfig генерирует конфигурацию машины +func (g *DefaultGenerator) GenerateMachineConfig(data *InitData) (string, error) { + // Простая заглушка + return fmt.Sprintf("# Machine config for %s", data.Hostname), nil +} + +// GenerateNodeConfig генерирует конфигурацию ноды +func (g *DefaultGenerator) GenerateNodeConfig(filename string, data *InitData, values *ValuesYAML) error { + // Заглушка + return nil +} + +// SaveChartYAML сохраняет Chart.yaml +func (g *DefaultGenerator) SaveChartYAML(chart ChartYAML) error { + // Заглушка + return nil +} + +// SaveValuesYAML сохраняет values.yaml +func (g *DefaultGenerator) SaveValuesYAML(values ValuesYAML) error { + // Заглушка + return nil +} + +// LoadValuesYAML загружает values.yaml +func (g *DefaultGenerator) LoadValuesYAML() (*ValuesYAML, error) { + // Заглушка + return &ValuesYAML{}, nil +} + +// GenerateBootstrapConfig генерирует конфигурацию bootstrap +func (g *DefaultGenerator) GenerateBootstrapConfig(data *InitData) error { + // Заглушка + return nil +} + +// UpdateValuesYAMLWithNode обновляет values.yaml с информацией о ноде +func (g *DefaultGenerator) UpdateValuesYAMLWithNode(data *InitData) error { + // Заглушка + return nil +} + +// GenerateSecretsBundle генерирует bundle секретов +func (g *DefaultGenerator) GenerateSecretsBundle(data *InitData) error { + // Заглушка + return nil +} + +// LoadSecretsBundle загружает bundle секретов +func (g *DefaultGenerator) LoadSecretsBundle() (interface{}, error) { + // Заглушка + return nil, nil +} + +// ValidateSecretsBundle валидирует bundle секретов +func (g *DefaultGenerator) ValidateSecretsBundle() error { + // Заглушка + return nil +} + +// SaveSecretsBundle сохраняет bundle секретов +func (g *DefaultGenerator) SaveSecretsBundle(bundle *secrets.Bundle) error { + // Заглушка + return nil +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/generate.go b/internal/pkg/ui/initwizard/generate.go new file mode 100644 index 0000000..3f28dc8 --- /dev/null +++ b/internal/pkg/ui/initwizard/generate.go @@ -0,0 +1,377 @@ +package initwizard + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/cozystack/talm/pkg/generated" + "github.com/siderolabs/talos/cmd/talosctl/cmd/mgmt/gen" + "github.com/siderolabs/talos/pkg/machinery/config" + "github.com/siderolabs/talos/pkg/machinery/config/generate" + "github.com/siderolabs/talos/pkg/machinery/config/generate/secrets" + "gopkg.in/yaml.v3" +) + +// GenerateFromTUI генерирует конфигурации из TUI +func GenerateFromTUI(data *InitData) error { + log.Printf("DEBUG GenerateFromTUI: Starting with preset=%s, clusterName=%s", data.Preset, data.ClusterName) + var ( + secretsBundle *secrets.Bundle + versionContract *config.VersionContract + err error + ) + + // 1. Validate preset + log.Printf("DEBUG GenerateFromTUI: Validating preset: %s", data.Preset) + if !isValidPreset(data.Preset) { + return fmt.Errorf("invalid preset: %s. Valid presets: %s", data.Preset, generated.AvailablePresets) + } + log.Printf("DEBUG GenerateFromTUI: Preset valid") + + // 2. Parse Talos version + if data.TalosVersion != "" { + versionContract, err = config.ParseContractFromVersion(data.TalosVersion) + if err != nil { + return fmt.Errorf("invalid talos-version: %w", err) + } + } + + // 3. Create secrets bundle + secretsBundle, err = secrets.NewBundle(secrets.NewFixedClock(time.Now()), versionContract) + if err != nil { + return fmt.Errorf("failed to create secrets bundle: %w", err) + } + + // 4. Setup generation options + genOptions := []generate.Option{generate.WithSecretsBundle(secretsBundle)} + if versionContract != nil { + genOptions = append(genOptions, generate.WithVersionContract(versionContract)) + } + + // 5. Write secrets.yaml + if err := writeSecretsBundleToFile(secretsBundle); err != nil { + return err + } + + // 6. Generate Talos config bundle + configBundle, err := gen.GenerateConfigBundle( + genOptions, + data.ClusterName, + data.APIServerURL, + "", + []string{}, + []string{}, + []string{}, + ) + if err != nil { + return fmt.Errorf("failed to generate config bundle: %w", err) + } + + // 7. Set endpoint + configBundle.TalosConfig().Contexts[data.ClusterName].Endpoints = []string{"127.0.0.1"} + + // 8. Write talosconfig + talosconfigFile := "talosconfig" + tcBytes, err := yaml.Marshal(configBundle.TalosConfig()) + if err != nil { + return fmt.Errorf("failed to marshal talosconfig: %w", err) + } + + if err := writeToDestination(tcBytes, talosconfigFile, 0o644); err != nil { + return err + } + + // 9. Write preset files с подстановкой реальных значений + log.Printf("DEBUG GenerateFromTUI: Writing preset files") + if err := writePresetCharts(data); err != nil { + log.Printf("DEBUG GenerateFromTUI: Error writing preset files: %v", err) + return err + } + + // 10. Write library chart (talm/) + log.Printf("DEBUG GenerateFromTUI: Writing talm library chart") + if err := writeTalmLibraryChart(); err != nil { + log.Printf("DEBUG GenerateFromTUI: Error writing talm library chart: %v", err) + return err + } + + log.Printf("DEBUG GenerateFromTUI: Completed successfully") + return nil +} + +// +// ------------------- HELPERS ------------------- +// + +func isValidPreset(preset string) bool { + presets, err := generated.AvailablePresets() + if err != nil { + return false + } + for _, p := range presets { + if p == preset { + return true + } + } + return false +} + +func writeSecretsBundleToFile(bundle *secrets.Bundle) error { + bundleBytes, err := yaml.Marshal(bundle) + if err != nil { + return fmt.Errorf("failed to marshal secrets bundle: %w", err) + } + + return writeToDestination(bundleBytes, "secrets.yaml", 0o644) +} + +func writePresetCharts(data *InitData) error { + log.Printf("DEBUG writePresetCharts: Starting for preset %s", data.Preset) + presetFiles, err := generated.PresetFiles() + if err != nil { + return fmt.Errorf("failed to get preset files: %w", err) + } + for path, content := range presetFiles { + parts := strings.SplitN(path, "/", 2) + if len(parts) < 2 { + continue + } + + chartName := parts[0] + filePath := parts[1] + + log.Printf("DEBUG writePresetCharts: Processing %s, chartName=%s, filePath=%s", path, chartName, filePath) + + if chartName == data.Preset { + log.Printf("DEBUG writePresetCharts: Matched preset, processing %s", path) + dst := filepath.Join(filePath) + log.Printf("DEBUG writePresetCharts: dst=%s", dst) + + if err := os.MkdirAll(filepath.Dir(dst), os.ModePerm); err != nil { + return err + } + + // Форматируем содержимое файлов + formattedContent := formatFileContent(content, path, data) + + log.Printf("DEBUG writePresetCharts: Writing file to %s", dst) + if err := writeToDestination([]byte(formattedContent), dst, 0o644); err != nil { + log.Printf("DEBUG writePresetCharts: Error writing file: %v", err) + return err + } + } + } + log.Printf("DEBUG writePresetCharts: Completed") + return nil +} + +func formatFileContent(content, filePath string, data *InitData) string { + // Форматируем Chart.yaml + if strings.HasSuffix(filePath, "Chart.yaml") { + return fmt.Sprintf(content, data.ClusterName, "0.1.0") + } + + // Форматируем values.yaml через парсинг YAML + if strings.HasSuffix(filePath, "values.yaml") { + var values map[string]interface{} + if err := yaml.Unmarshal([]byte(content), &values); err != nil { + // Если не удалось распарсить, возвращаем оригинальный контент + return content + } + + // Обновляем общие поля + if endpoint, ok := values["endpoint"].(string); ok && (endpoint == "https://192.168.100.10:6443" || endpoint == "") { + if data.APIServerURL != "" { + values["endpoint"] = data.APIServerURL + } + } + + // Обновляем подсети + if podSubnets, ok := values["podSubnets"].([]interface{}); ok && len(podSubnets) > 0 { + if subnet, ok := podSubnets[0].(string); ok && (subnet == "10.244.0.0/16" || subnet == "") { + if data.PodSubnets != "" { + values["podSubnets"] = []string{data.PodSubnets} + } + } + } + + if serviceSubnets, ok := values["serviceSubnets"].([]interface{}); ok && len(serviceSubnets) > 0 { + if subnet, ok := serviceSubnets[0].(string); ok && (subnet == "10.96.0.0/16" || subnet == "") { + if data.ServiceSubnets != "" { + values["serviceSubnets"] = []string{data.ServiceSubnets} + } + } + } + + if advertisedSubnets, ok := values["advertisedSubnets"].([]interface{}); ok && len(advertisedSubnets) > 0 { + if subnet, ok := advertisedSubnets[0].(string); ok && (subnet == "192.168.100.0/24" || subnet == "") { + if data.AdvertisedSubnets != "" { + values["advertisedSubnets"] = []string{data.AdvertisedSubnets} + } + } + } + + // Обновляем поля для cozystack + if data.Preset == "cozystack" { + if domain, ok := values["clusterDomain"].(string); ok && (domain == "cozy.local" || domain == "") { + if data.ClusterDomain != "" { + values["clusterDomain"] = data.ClusterDomain + } + } + if floatingIP, ok := values["floatingIP"].(string); ok && (floatingIP == "192.168.100.10" || floatingIP == "") { + if data.FloatingIP != "" { + values["floatingIP"] = data.FloatingIP + } + } + if image, ok := values["image"].(string); ok && (image == "ghcr.io/cozystack/cozystack/talos:v1.10.5" || image == "") { + if data.Image != "" { + values["image"] = data.Image + } + } + if _, ok := values["oidcIssuerUrl"]; ok { + if data.OIDCIssuerURL != "" { + values["oidcIssuerUrl"] = data.OIDCIssuerURL + } + } + if nr, ok := values["nr_hugepages"].(int); ok && nr == 0 { + if data.NrHugepages > 0 { + values["nr_hugepages"] = data.NrHugepages + } + } + } + + // Обновляем certSANs если есть + if certSANs, ok := values["certSANs"].([]interface{}); ok { + if len(certSANs) == 0 { + // Добавляем базовые certSANs + values["certSANs"] = []string{"127.0.0.1"} + if data.FloatingIP != "" { + certSANsArray := values["certSANs"].([]string) + certSANsArray = append(certSANsArray, data.FloatingIP) + values["certSANs"] = certSANsArray + } + } + } + + // Сериализуем обратно в YAML + updatedContent, err := yaml.Marshal(values) + if err != nil { + return content + } + + return string(updatedContent) + } + + // Для других файлов возвращаем как есть + return content +} + +func writeTalmLibraryChart() error { + presetFiles, err := generated.PresetFiles() + if err != nil { + return fmt.Errorf("failed to get preset files: %w", err) + } + for path, content := range presetFiles { + parts := strings.SplitN(path, "/", 2) + if len(parts) < 2 { + continue + } + + if parts[0] != "talm" { + continue + } + + filePath := parts[1] + dst := filepath.Join("charts", "talm", filePath) + + if err := os.MkdirAll(filepath.Dir(dst), os.ModePerm); err != nil { + return err + } + + // Format Chart.yaml with chart name and version + if strings.HasSuffix(path, "Chart.yaml") { + content = fmt.Sprintf(content, "talm", "0.1.0") + } + + if err := writeToDestination([]byte(content), dst, 0o644); err != nil { + return err + } + } + return nil +} + +func writeToDestination(data []byte, destination string, permissions os.FileMode) error { + // Check if file already exists + if _, err := os.Stat(destination); err == nil { + return fmt.Errorf("file %q already exists", destination) + } + + if err := os.MkdirAll(filepath.Dir(destination), os.ModePerm); err != nil { + return fmt.Errorf("failed to create output dir: %w", err) + } + + err := os.WriteFile(destination, data, permissions) + if err == nil { + fmt.Println("Created", destination) + } + + return err +} + +// writeToDestinationNoCheck записывает файл без проверки существования (для автоинкремента) +func writeToDestinationNoCheck(data []byte, destination string, permissions os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(destination), os.ModePerm); err != nil { + return fmt.Errorf("failed to create output dir: %w", err) + } + + err := os.WriteFile(destination, data, permissions) + if err == nil { + fmt.Println("Created", destination) + } + + return err +} + +// generateNodeFileName генерирует имя файла для ноды с автоинкрементом +func generateNodeFileName(originalFilePath string) (string, error) { + // Создаем директорию nodes/ если её нет + nodesDir := "nodes" + if err := os.MkdirAll(nodesDir, os.ModePerm); err != nil { + return "", fmt.Errorf("failed to create nodes directory: %w", err) + } + + // Сканируем существующие файлы nodes/node*.yaml + pattern := filepath.Join(nodesDir, "node*.yaml") + files, err := filepath.Glob(pattern) + if err != nil { + return "", fmt.Errorf("failed to scan existing node files: %w", err) + } + + // Определяем максимальный номер + maxNum := 0 + for _, file := range files { + // Извлекаем номер из имени файла + base := filepath.Base(file) + if strings.HasPrefix(base, "node") && strings.HasSuffix(base, ".yaml") { + numStr := strings.TrimPrefix(base, "node") + numStr = strings.TrimSuffix(numStr, ".yaml") + if numStr != "" { + if nodeNum, err := strconv.Atoi(numStr); err == nil { + if nodeNum > maxNum { + maxNum = nodeNum + } + } + } + } + } + + // Генерируем новое имя файла + nextNum := maxNum + 1 + newFilename := fmt.Sprintf("node%d.yaml", nextNum) + return filepath.Join(nodesDir, newFilename), nil +} diff --git a/internal/pkg/ui/initwizard/initwizard.go b/internal/pkg/ui/initwizard/initwizard.go new file mode 100644 index 0000000..5508e28 --- /dev/null +++ b/internal/pkg/ui/initwizard/initwizard.go @@ -0,0 +1,36 @@ +package initwizard + +// Этот файл содержит обновленную версию мастера инициализации, +// которая использует новые компоненты вместо монолитного кода. + +// RunInitWizard запускает мастер инициализации с использованием новых компонентов +func RunInitWizard() error { + wizard := NewWizard() + return wizard.Run() +} + +// CheckExistingFiles проверяет наличие существующих файлов конфигурации +func CheckExistingFiles() bool { + wizard := NewWizard() + return wizard.checkExistingFiles() +} + +// RunInitWizardWithConfig запускает мастер с пользовательской конфигурацией +func RunInitWizardWithConfig(config InitData) error { + wizard := NewWizard() + return wizard.RunWithCustomConfig(config) +} + +// NewInitWizard создает новый экземпляр мастера инициализации с поддержкой rootDir +func NewInitWizard(rootDir string) Wizard { + // Создаем мастер с настройками по умолчанию + wizard := NewWizard() + + // Если указан rootDir, меняем рабочую директорию + if rootDir != "." { + // В реальном приложении здесь можно добавить логику + // для работы с другими директориями + } + + return wizard +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/interfaces.go b/internal/pkg/ui/initwizard/interfaces.go new file mode 100644 index 0000000..179b853 --- /dev/null +++ b/internal/pkg/ui/initwizard/interfaces.go @@ -0,0 +1,98 @@ +package initwizard + +import ( + "context" + + "github.com/rivo/tview" + "github.com/siderolabs/talos/pkg/machinery/config/generate/secrets" +) + +// Wizard interface of the main initialization wizard component +type Wizard interface { + Run() error + getData() *InitData + getApp() *tview.Application + getPages() *tview.Pages + setupInputCapture() + GetScanner() NetworkScanner +} + +// Validator interface of the validation component +type Validator interface { + ValidateNetworkCIDR(cidr string) error + ValidateClusterName(name string) error + ValidateHostname(hostname string) error + ValidateRequiredField(value, fieldName string) error + ValidateIP(ip string) error + ValidateVIP(vip string) error + ValidateDNSservers(dns string) error + ValidateNetworkConfig(addresses, gateway, dnsServers string) error + ValidateNodeType(nodeType string) error + ValidatePreset(preset string) error + ValidateAPIServerURL(url string) error +} + +// DataProcessor interface of the data processing component +type DataProcessor interface { + FilterAndSortNodes(nodes []NodeInfo) []NodeInfo + ExtractHardwareInfo(ip string) (Hardware, error) + ProcessScanResults(results []NodeInfo) []NodeInfo + CalculateResourceStats(node NodeInfo) (cpu, ram, disks int) + RemoveDuplicatesByMAC(nodes []NodeInfo) []NodeInfo +} + +// Generator interface of the configuration generation component +type Generator interface { + GenerateChartYAML(clusterName, preset string) (ChartYAML, error) + GenerateValuesYAML(data *InitData) (ValuesYAML, error) + GenerateMachineConfig(data *InitData) (string, error) + GenerateNodeConfig(filename string, data *InitData, values *ValuesYAML) error + SaveChartYAML(chart ChartYAML) error + SaveValuesYAML(values ValuesYAML) error + LoadValuesYAML() (*ValuesYAML, error) + GenerateBootstrapConfig(data *InitData) error + UpdateValuesYAMLWithNode(data *InitData) error + // Secrets management + GenerateSecretsBundle(data *InitData) error + LoadSecretsBundle() (interface{}, error) + ValidateSecretsBundle() error + SaveSecretsBundle(bundle *secrets.Bundle) error +} + +// NetworkScanner interface of the network scanning component +type NetworkScanner interface { + ScanNetwork(ctx context.Context, cidr string) ([]NodeInfo, error) + ScanNetworkWithProgress(ctx context.Context, cidr string, progressFunc func(int)) ([]NodeInfo, error) + IsTalosNode(ctx context.Context, ip string) bool + CollectNodeInfo(ctx context.Context, ip string) (NodeInfo, error) + CollectNodeInfoEnhanced(ctx context.Context, ip string) (NodeInfo, error) + ParallelScan(ctx context.Context, ips []string) ([]NodeInfo, error) +} + +// Presenter interface of the user interface component +type Presenter interface { + ShowStep1Form(data *InitData) *tview.Form + ShowGenericStep2(data *InitData) + ShowCozystackScan(data *InitData) + ShowAddNodeWizard(data *InitData) + ShowNodeSelection(data *InitData, title string) + ShowNodeConfig(data *InitData) + ShowNetworkConfig(data *InitData) + ShowProgressModal(message string, task func()) + ShowScanningModal(scanFunc func(context.Context, func(int)), ctx context.Context) + ShowErrorModal(message string) + ShowSuccessModal(message string) + ShowConfigConfirmation(data *InitData) + ShowBootstrapPrompt(data *InitData, nodeFileName string) + ShowFirstNodeConfig(data *InitData) +} + +// UIHelper interface of UI helper functions +type UIHelper interface { + CreateButton(text string, handler func()) *tview.Button + CreateInputField(label, initialText string, fieldWidth int, validator func(string), changed func(string)) *tview.InputField + CreateDropDown(label string, options []string, initialIndex int, changed func(string, int)) *tview.DropDown + SetFormStyle(form *tview.Form, title string) + AddFormButtons(form *tview.Form, buttons map[string]func()) + SwitchPage(pages *tview.Pages, pageName string) +} diff --git a/internal/pkg/ui/initwizard/network.go b/internal/pkg/ui/initwizard/network.go new file mode 100644 index 0000000..0af03cb --- /dev/null +++ b/internal/pkg/ui/initwizard/network.go @@ -0,0 +1,284 @@ +package initwizard + +import ( + "context" + "fmt" + "net" + "sync" + "time" +) + +// ConnectionPool представляет пул сетевых соединений +type ConnectionPool struct { + connections map[string]net.Conn + mutex sync.RWMutex + maxIdle time.Duration + maxLifetime time.Duration + metrics *PoolMetrics +} + +// PoolMetrics метрики пула соединений +type PoolMetrics struct { + Created int64 + Reused int64 + Closed int64 + Active int64 + GetCalls int64 + PutCalls int64 +} + +// NewConnectionPool создает новый пул соединений +func NewConnectionPool(maxIdle, maxLifetime time.Duration) *ConnectionPool { + return &ConnectionPool{ + connections: make(map[string]net.Conn), + maxIdle: maxIdle, + maxLifetime: maxLifetime, + metrics: &PoolMetrics{}, + } +} + +// Get получает соединение из пула +func (p *ConnectionPool) Get(network, addr string) (net.Conn, error) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.metrics.GetCalls++ + + key := network + ":" + addr + + // Проверяем существующие соединения + if conn, exists := p.connections[key]; exists { + // Проверяем, не истекло ли соединение + if time.Since(conn.(*timedConn).lastUsed) < p.maxIdle { + p.metrics.Reused++ + conn.(*timedConn).lastUsed = time.Now() + return conn, nil + } + // Закрываем истекшее соединение + conn.Close() + delete(p.connections, key) + p.metrics.Closed++ + } + + // Создаем новое соединение + conn, err := net.Dial(network, addr) + if err != nil { + return nil, err + } + + timedConn := &timedConn{ + Conn: conn, + created: time.Now(), + lastUsed: time.Now(), + network: network, + addr: addr, + } + + p.connections[key] = timedConn + p.metrics.Created++ + p.metrics.Active++ + + return timedConn, nil +} + +// Put возвращает соединение в пул +func (p *ConnectionPool) Put(conn net.Conn) error { + p.mutex.Lock() + defer p.mutex.Unlock() + p.metrics.PutCalls++ + + if timedConn, ok := conn.(*timedConn); ok { + key := timedConn.network + ":" + timedConn.addr + + // Проверяем lifetime соединения + if time.Since(timedConn.created) > p.maxLifetime { + conn.Close() + delete(p.connections, key) + p.metrics.Closed++ + p.metrics.Active-- + return nil + } + + // Обновляем время использования + timedConn.lastUsed = time.Now() + p.connections[key] = timedConn + } + + return nil +} + +// Close закрывает все соединения в пуле +func (p *ConnectionPool) Close() error { + p.mutex.Lock() + defer p.mutex.Unlock() + + for key, conn := range p.connections { + conn.Close() + delete(p.connections, key) + p.metrics.Closed++ + } + p.metrics.Active = 0 + + return nil +} + +// GetMetrics возвращает метрики пула +func (p *ConnectionPool) GetMetrics() PoolMetrics { + p.mutex.RLock() + defer p.mutex.RUnlock() + return *p.metrics +} + +// Size возвращает текущий размер пула +func (p *ConnectionPool) Size() int { + p.mutex.RLock() + defer p.mutex.RUnlock() + return len(p.connections) +} + +// timedConn представляет соединение с метаданными +type timedConn struct { + net.Conn + created time.Time + lastUsed time.Time + network string + addr string +} + +// NetworkClient клиент для сетевых операций с пулом соединений +type NetworkClient struct { + pool *ConnectionPool + timeout time.Duration +} + +// NewNetworkClient создает нового сетевого клиента +func NewNetworkClient(pool *ConnectionPool, timeout time.Duration) *NetworkClient { + return &NetworkClient{ + pool: pool, + timeout: timeout, + } +} + +// ExecuteWithConnection выполняет операцию с соединением из пула +func (nc *NetworkClient) ExecuteWithConnection(network, addr string, operation func(net.Conn) error) error { + conn, err := nc.pool.Get(network, addr) + if err != nil { + return NewNetworkErrorWithCause( + "NET_001", + "не удалось получить соединение", + fmt.Sprintf("сеть: %s, адрес: %s", network, addr), + err, + ) + } + defer nc.pool.Put(conn) + + // Устанавливаем таймаут + if nc.timeout > 0 { + conn.SetDeadline(time.Now().Add(nc.timeout)) + defer conn.SetDeadline(time.Time{}) + } + + return operation(conn) +} + +// ScanWithPool сканирование с использованием пула соединений +func (nc *NetworkClient) ScanWithPool(ctx context.Context, cidr string, operation func(context.Context, string, net.Conn) error) error { + // Упрощенная реализация - получаем список IP из CIDR + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return NewNetworkErrorWithCause( + "NET_002", + "некорректная CIDR нотация", + fmt.Sprintf("CIDR: %s", cidr), + err, + ) + } + + // Создаем канал для ограничения параллелизма + const maxWorkers = 10 + workerChan := make(chan struct{}, maxWorkers) + defer close(workerChan) + + // Обрабатываем IP адреса + for ip := ipNet.IP.Mask(ipNet.Mask); ipNet.Contains(ip); ip = nextIP(ip) { + select { + case <-ctx.Done(): + return WrapError(ctx.Err(), ErrNetwork, "NET_003", "сканирование отменено", "операция была отменена пользователем") + case workerChan <- struct{}{}: + go func(ip net.IP) { + defer func() { <-workerChan }() + + addr := ip.String() + ":50000" + err := nc.ExecuteWithConnection("tcp", addr, func(conn net.Conn) error { + return operation(ctx, addr, conn) + }) + if err != nil { + // Логируем ошибку, но продолжаем + // В реальном приложении можно добавить более продвинутую обработку + } + }(ip) + } + } + + return nil +} + +// nextIP возвращает следующий IP адрес +func nextIP(ip net.IP) net.IP { + next := make(net.IP, len(ip)) + copy(next, ip) + for j := len(next) - 1; j >= 0; j-- { + next[j]++ + if next[j] > 0 { + break + } + } + return next +} + +// RateLimiter ограничитель скорости для сетевых операций +type RateLimiter struct { + tokens int + capacity int + lastRefill time.Time + mutex sync.Mutex +} + +// NewRateLimiter создает новый ограничитель скорости +func NewRateLimiter(capacity int) *RateLimiter { + return &RateLimiter{ + capacity: capacity, + tokens: capacity, + lastRefill: time.Now(), + } +} + +// Allow проверяет, разрешена ли операция +func (rl *RateLimiter) Allow() bool { + rl.mutex.Lock() + defer rl.mutex.Unlock() + + now := time.Now() + elapsed := now.Sub(rl.lastRefill) + + // Пополняем токены (1 токен в секунду) + tokensToAdd := int(elapsed.Seconds()) + if tokensToAdd > 0 { + rl.tokens = min(rl.capacity, rl.tokens+tokensToAdd) + rl.lastRefill = now + } + + if rl.tokens > 0 { + rl.tokens-- + return true + } + + return false +} + +// min возвращает минимум из двух чисел +func min(a, b int) int { + if a < b { + return a + } + return b +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/presenter.go b/internal/pkg/ui/initwizard/presenter.go new file mode 100644 index 0000000..dee252f --- /dev/null +++ b/internal/pkg/ui/initwizard/presenter.go @@ -0,0 +1,1770 @@ +package initwizard + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "sort" + "strings" + "time" + + "github.com/cozystack/talm/pkg/engine" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/siderolabs/talos/pkg/machinery/constants" +) + +var stateToPage = map[WizardState]string{ + StatePreset: "step1-simple", + StateEndpoint: "step2-generic", + StateScanning: "scanning", + StateNodeSelect: "node-selection", + StateNodeConfig: "node-config", + StateConfirm: "confirm", + StateGenerate: "generate", + StateDone: "success", + StateAddNodeScan: "add-node-scan", + StateCozystackScan: "cozystack-scan", + StateVIPConfig: "vip-config", + StateNetworkConfig: "network-config", + StateNodeDetails: "node-details", +} + +// PresenterImpl implements the Presenter interface +type PresenterImpl struct { + app *tview.Application + pages *tview.Pages + data *InitData + wizard Wizard + controller *WizardController + cancelScan context.CancelFunc + scanningModal tview.Primitive +} + +func (p *PresenterImpl) Go(to WizardState) { + log.Printf("[DEBUG-TRANSITION] Попытка перехода из состояния %s в %s", p.controller.state, to) + if err := p.controller.Transition(to); err != nil { + log.Printf("[DEBUG-TRANSITION] ОШИБКА ПЕРЕХОДА: %v", err) + p.ShowErrorModal(err.Error()) + return + } + log.Printf("[DEBUG-TRANSITION] Переход успешен, текущее состояние: %s", p.controller.state) + + page, ok := stateToPage[to] + if !ok { + p.ShowErrorModal(fmt.Sprintf("no page for state %v", to)) + return + } + + p.pages.SwitchToPage(page) +} + +// PresetDescriptions содержит описания доступных preset'ов +var PresetDescriptions = map[string]string{ + "generic": "Стандартный кластер Kubernetes с базовой конфигурацией. Подходит для большинства случаев использования.", + "cozystack": "Платформа Cozystack с расширенными возможностями сети и хранения. Включает дополнительные модули ядра и оптимизации.", +} + +// NewPresenter creates a new presenter instance +func NewPresenter(app *tview.Application, pages *tview.Pages, data *InitData, wizard Wizard) *PresenterImpl { + controller := NewWizardController(data) + + return &PresenterImpl{ + app: app, + pages: pages, + data: data, + wizard: wizard, + controller: controller, + } +} + +// ShowStep1Form отображает первую форму мастера +func (p *PresenterImpl) ShowStep1Form(data *InitData) *tview.Form { + log.Printf("ДИАГНОСТИКА PRESENTER: Вызван ShowStep1Form") + log.Printf("ДИАГНОСТИКА PRESENTER: data=%v", data) + log.Printf("ДИАГНОСТИКА PRESENTER: p.app=%v", p.app) + log.Printf("ДИАГНОСТИКА PRESENTER: p.pages=%v", p.pages) + + // Проверяем входные параметры + if data == nil { + log.Printf("КРИТИЧЕСКАЯ ОШИБКА PRESENTER: data равен nil!") + return nil + } + + if p.app == nil { + log.Printf("КРИТИЧЕСКАЯ ОШИБКА PRESENTER: p.app равен nil!") + return nil + } + + if p.pages == nil { + log.Printf("КРИТИЧЕСКАЯ ОШИБКА PRESENTER: p.pages равен nil!") + return nil + } + + log.Printf("ДИАГНОСТИКА PRESENTER: Все параметры в порядке, продолжаем создание формы") + + // Проверяем доступность generated.AvailablePresets + log.Printf("ДИАГНОСТИКА PRESENTER: Проверяем generated.AvailablePresets...") + + log.Printf("ДИАГНОСТИКА PRESENTER: Создаем простую форму...") + + // Определяем начальный индекс для dropdown на основе текущего preset + var initialIndex int + if data.Preset == "cozystack" { + initialIndex = 1 + } else { + initialIndex = 0 + if data.Preset == "" { + data.Preset = "generic" + } + } + + // Создаем простую форму без сложных контейнеров + form := tview.NewForm(). + AddDropDown("Preset", []string{"generic", "cozystack"}, initialIndex, func(option string, index int) { + log.Printf("ДИАГНОСТИКА PRESENTER: Изменен preset: %s", option) + data.Preset = option + }). + AddInputField("Имя Кластера", data.ClusterName, 20, nil, func(text string) { + data.ClusterName = text + }) + + form. + AddButton("Next", func() { + log.Printf("ДИАГНОСТИКА PRESENTER: ========= КНОПКА 'ДАЛЕЕ' НАЖАТА =========") + log.Printf("ДИАГНОСТИКА PRESENTER: Нажата кнопка 'Next', preset = %s", data.Preset) + log.Printf("ДИАГНОСТИКА PRESENTER: Начинаем обработку нажатия кнопки...") + + if data.Preset == "generic" || data.Preset == "cozystack" { + log.Printf("ДИАГНОСТИКА PRESENTER: Переход к ShowGenericStep2") + log.Printf("ДИАГНОСТИКА PRESENTER: Вызываем ShowGenericStep2 для preset = %s", data.Preset) + p.ShowGenericStep2(data) + log.Printf("ДИАГНОСТИКА PRESENTER: ShowGenericStep2 завершен") + } else { + log.Printf("ДИАГНОСТИКА PRESENTER: Некорректный preset: %s", data.Preset) + p.ShowErrorModal(fmt.Sprintf("Некорректный preset: %s. Введите 'generic' или 'cozystack'", data.Preset)) + } + log.Printf("ДИАГНОСТИКА PRESENTER: Обработка нажатия кнопки завершена") + log.Printf("ДИАГНОСТИКА PRESENTER: ========= КНОПКА 'ДАЛЕЕ' ОБРАБОТАНА =========") + }). + AddButton("Cancel", func() { + log.Printf("ДИАГНОСТИКА PRESENTER: ========= КНОПКА 'ОТМЕНА' НАЖАТА =========") + p.app.Stop() + }) + + log.Printf("ДИАГНОСТИКА PRESENTER: Устанавливаем границы формы...") + form.SetBorder(true).SetTitle("Talos Init Wizard - Шаг 1: Базовая Конфигурация").SetTitleAlign(tview.AlignLeft) + + log.Printf("ДИАГНОСТИКА PRESENTER: Добавляем страницу...") + p.pages.AddPage("step1-simple", form, true, true) + + log.Printf("ДИАГНОСТИКА PRESENTER: Переключаемся на страницу...") + p.Go(StatePreset) + + log.Printf("ДИАГНОСТИКА PRESENTER: Устанавливаем фокус...") + p.app.SetFocus(form) + + log.Printf("ДИАГНОСТИКА PRESENTER: Метод ShowStep1Form завершен успешно, возвращаем form") + + log.Printf("ДИАГНОСТИКА PRESENTER: ShowStep1Form полностью завершен") + return form +} + +// ShowGenericStep2 отображает вторую форму для Generic и Cozystack preset'ов +func (p *PresenterImpl) ShowGenericStep2(data *InitData) { + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: Начало выполнения ShowGenericStep2 для preset = %s", data.Preset) + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: data = %v", data) + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: Создаем форму...") + form := tview.NewForm(). + AddInputField("Kubernetes Endpoint", "", 30, nil, func(text string) { + data.APIServerURL = text + }). + AddInputField("Floating IP (опционально)", "", 20, nil, func(text string) { + data.FloatingIP = text + }) + + form. + AddButton("Next", func() { + p.initializeCluster(data) + }). + AddButton("Back", func() { + p.Go(StatePreset) + }). + AddButton("Cancel", func() { + p.app.Stop() + }) + + form.SetBorder(true).SetTitle(fmt.Sprintf("%s Preset - Дополнительная Конфигурация", strings.Title(data.Preset))).SetTitleAlign(tview.AlignLeft) + + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: Добавляем страницу...") + p.pages.AddPage("step2-generic", form, true, true) + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: Переключаемся на страницу...") + p.Go(StateEndpoint) + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: Устанавливаем фокус...") + p.app.SetFocus(form) + log.Printf("ДИАГНОСТИКА GENERIC-STEP2: Функция ShowGenericStep2 завершена успешно") +} +// ShowCozystackScan отображает сканирование для Cozystack +func (p *PresenterImpl) ShowCozystackScan(data *InitData) { + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Вызван ShowCozystackScan") + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: data = %v", data) + + form := tview.NewForm(). + AddInputField("Network to scan", "192.168.1.0/24", 20, nil, func(text string) { + data.NetworkToScan = text + }) + + form. + AddButton("Scan", func() { + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Нажата кнопка 'Scan'") + if data.NetworkToScan == "" { + p.ShowErrorModal("Please enter network to scan") + return + } + + p.showCozystackScanningModal(data) + }). + AddButton("Cancel", func() { + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Нажата кнопка 'Cancel'") + p.app.Stop() + }) + + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Устанавливаем границы формы...") + form.SetBorder(true).SetTitle("Cozystack Network Scan").SetTitleAlign(tview.AlignLeft) + + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Добавляем страницу...") + p.pages.AddPage("cozystack-scan", form, true, true) + + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Переключаемся на страницу...") + p.Go(StateCozystackScan) + + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: Устанавливаем фокус...") + p.app.SetFocus(form) + + log.Printf("ДИАГНОСТИКА COZYSTACK-SCAN: ShowCozystackScan завершен") +} + +// ShowAddNodeWizard отображает мастер добавления новой ноды +func (p *PresenterImpl) ShowAddNodeWizard(data *InitData) { + log.Printf("ДИАГНОСТИКА ADD-NODE: Вызван ShowAddNodeWizard") + log.Printf("ДИАГНОСТИКА ADD-NODE: data = %v", data) + log.Printf("[DEBUG-ADD-NODE] Текущее состояние контроллера перед установкой: %s", p.controller.state) + + p.Go(StateAddNodeScan) + + form := tview.NewForm(). + AddInputField("Network to scan", "192.168.1.0/24", 20, nil, func(text string) { + data.NetworkToScan = text + }) + + form. + AddButton("Scan", func() { + log.Printf("ДИАГНОСТИКА ADD-NODE: Нажата кнопка 'Scan'") + log.Printf("[DEBUG-ADD-NODE] Состояние при нажатии Scan: %s", p.controller.state) + if data.NetworkToScan == "" { + p.ShowErrorModal("Please enter network to scan") + return + } + + p.ShowScanningModal(func(ctx context.Context, updateProgress func(int)) { + p.performNetworkScan(data, updateProgress) + }, context.Background()) + }). + AddButton("Cancel", func() { + log.Printf("ДИАГНОСТИКА ADD-NODE: Нажата кнопка 'Cancel'") + p.app.Stop() + }) + + log.Printf("ДИАГНОСТИКА ADD-NODE: Устанавливаем границы формы...") + form.SetBorder(true).SetTitle("Add New Node - Network Scan").SetTitleAlign(tview.AlignLeft) + + log.Printf("ДИАГНОСТИКА ADD-NODE: Добавляем страницу...") + p.pages.AddPage("add-node-scan", form, true, true) + + log.Printf("ДИАГНОСТИКА ADD-NODE: Переключаемся на страницу...") + p.SwitchPage(p.pages, "add-node-scan") + + log.Printf("[DEBUG-ADD-NODE] ShowAddNodeWizard завершен, состояние контроллера: %s", p.controller.state) +} + +// ShowNodeSelection отображает выбор ноды +func (p *PresenterImpl) ShowNodeSelection(data *InitData, title string) { + list := tview.NewList(). + SetSelectedFunc(func(index int, name, secondName string, shortcut rune) { + data.SelectedNode = data.DiscoveredNodes[index].IP + data.SelectedNodeInfo = data.DiscoveredNodes[index] + p.ShowNodeConfig(data) + }) + + for i, node := range data.DiscoveredNodes { + desc := fmt.Sprintf("IP: %s", node.IP) + if node.Hostname != "" && node.Hostname != node.IP { + desc += fmt.Sprintf(", Hostname: %s", node.Hostname) + } + + // Добавляем детальную информацию об оборудовании + if node.Manufacturer != "" { + desc += fmt.Sprintf(", CPU: %s", node.Manufacturer) + } + if node.CPU > 0 { + desc += fmt.Sprintf(" (%d cores)", node.CPU) + } + if node.RAM > 0 { + desc += fmt.Sprintf(", RAM: %d GB", node.RAM) + } + if len(node.Disks) > 0 { + totalSize := 0 + for _, disk := range node.Disks { + totalSize += disk.Size + } + desc += fmt.Sprintf(", Storage: %d disks (%d GB)", len(node.Disks), totalSize/1024/1024/1024) + } + + // Добавляем информацию о сетевых интерфейсах + if len(node.Hardware.Interfaces) > 0 { + activeInterfaces := 0 + for _, iface := range node.Hardware.Interfaces { + if iface.Name != "lo" && iface.MAC != "" { + activeInterfaces++ + } + } + if activeInterfaces > 0 { + desc += fmt.Sprintf(", Network: %d interfaces", activeInterfaces) + } + } + + // Добавляем информацию о типе узла + if node.Type != "" { + desc += fmt.Sprintf(", Type: %s", node.Type) + } + + list.AddItem(node.Name, desc, rune('1'+i), nil) + } + + buttons := tview.NewForm(). + AddButton("Детали", func() { + p.ShowNodeDetails(data) + }). + AddButton("Back", func() { + if title == "Select First Control Plane Node" { + p.SwitchPage(p.pages, "cozystack-scan") + } else { + p.SwitchPage(p.pages, "add-node-scan") + } + }). + AddButton("Cancel", func() { + p.app.Stop() + }) + + buttons.SetButtonsAlign(tview.AlignCenter) + + flex := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(list, 0, 1, true). + AddItem(buttons, 3, 1, true) + + flex.SetBorder(true).SetTitle(title).SetTitleAlign(tview.AlignLeft) + + flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyTab { + if p.app.GetFocus() == list { + p.app.SetFocus(buttons) + } else { + p.app.SetFocus(list) + } + return nil + } + return event + }) + + p.pages.AddPage("node-selection", flex, true, true) + p.Go(StateNodeSelect) + p.app.SetFocus(list) +} + +// ShowNodeConfig отображает конфигурацию ноды +func (p *PresenterImpl) ShowNodeConfig(data *InitData) { + defaultHostname := data.SelectedNodeInfo.Hostname + if defaultHostname == "" || defaultHostname == data.SelectedNode { + defaultHostname = data.SelectedNode + } + + disks := data.SelectedNodeInfo.Disks + log.Printf("Disks: %v", disks) + diskOptions := make([]string, len(disks)) + for i, disk := range disks { + sizeGB := disk.Size / 1024 / 1024 / 1024 + desc := fmt.Sprintf("%s (%d GB", disk.DevPath, sizeGB) + if disk.Model != "" { + desc += fmt.Sprintf(", %s", disk.Model) + } + if disk.Transport != "" { + desc += fmt.Sprintf(", %s", disk.Transport) + } + desc += ")" + diskOptions[i] = desc + } + + allInterfaces := data.SelectedNodeInfo.Hardware.Interfaces + var interfaces []Interface + + log.Printf("[INTERFACE-FILTER] Всего найдено интерфейсов: %d", len(allInterfaces)) + + for i, iface := range allInterfaces { + log.Printf("[INTERFACE-FILTER] Проверяем интерфейс %d: %s [MAC: %s] [IPs: %v]", + i, iface.Name, iface.MAC, iface.IPs) + + // Фильтрация в точном соответствии с shell-скриптом + includeInterface := false + + // Пропускаем явно нежелательные интерфейсы + if iface.Name == "lo" || iface.Name == "docker0" || strings.HasPrefix(iface.Name, "br-") || + strings.HasPrefix(iface.Name, "veth") || strings.HasPrefix(iface.Name, "cali") { + log.Printf("[INTERFACE-FILTER] Пропускаем нежелательный интерфейс: %s", iface.Name) + continue + } + + // Проверяем соответствие паттерну валидных имен как в shell-скрипте + validPrefixes := []string{"eno", "eth", "enp", "enx", "ens", "bond"} + for _, prefix := range validPrefixes { + if strings.HasPrefix(iface.Name, prefix) { + includeInterface = true + log.Printf("[INTERFACE-FILTER] Interface %s соответствует префиксу %s", iface.Name, prefix) + break + } + } + + // Если не matched по префиксу, но есть MAC адрес - включаем (для виртуальных интерфейсов) + if !includeInterface && iface.MAC != "" { + includeInterface = true + log.Printf("[INTERFACE-FILTER] Включаем виртуальный интерфейс с MAC: %s", iface.Name) + } + + // Фильтруем интерфейсы с полностью нулевыми MAC адресами + // Отклоняем только полностью нулевые MAC (00:00:00:00:00:00), оставляем MAC с префиксом 00:00 + if includeInterface && iface.MAC != "" && iface.MAC == "00:00:00:00:00:00" { + log.Printf("[INTERFACE-FILTER] Пропускаем интерфейс с полностью нулевым MAC: %s (%s)", iface.Name, iface.MAC) + includeInterface = false + } + + if includeInterface { + interfaces = append(interfaces, iface) + log.Printf("[INTERFACE-FILTER] Добавлен интерфейс: %s [MAC: %s] [IPs: %v]", + iface.Name, iface.MAC, iface.IPs) + } + } + + log.Printf("[INTERFACE-FILTER] Отфильтровано интерфейсов: %d из %d", len(interfaces), len(allInterfaces)) + + // Сортируем интерфейсы: приоритет интерфейсам с IP Addressами + sort.Slice(interfaces, func(i, j int) bool { + // Interfaceы с IPv4 адресами идут первыми + hasIPi := false + hasIPj := false + + for _, ip := range interfaces[i].IPs { + if strings.Contains(ip, ".") { // IPv4 адрес + hasIPi = true + break + } + } + + for _, ip := range interfaces[j].IPs { + if strings.Contains(ip, ".") { // IPv4 адрес + hasIPj = true + break + } + } + + // Если один интерфейс имеет IP, а другой нет - первый идет первым + if hasIPi != hasIPj { + return hasIPi && !hasIPj + } + + // Если оба имеют или не имеют IP - сортируем по имени + return interfaces[i].Name < interfaces[j].Name + }) + + interfaceOptions := make([]string, len(interfaces)) + for i, iface := range interfaces { + // Создаем улучшенное отображение: interface_name MAC_address (IP/24) [↑/↓] + interfaceDisplay := fmt.Sprintf("%s %s", iface.Name, iface.MAC) + + // Добавляем IP Address с маской подсети если есть + if len(iface.IPs) > 0 { + // Находим первый IPv4 адрес (не IPv6) + var mainIP string + for _, ip := range iface.IPs { + // Проверяем, что это IPv4 адрес + if strings.Contains(ip, ".") { + mainIP = ip + break + } + } + + // Если нашли IPv4, добавляем маску /24 (стандарт для локальных сетей) + if mainIP != "" { + // Проверяем, есть ли уже маска в IP + if !strings.Contains(mainIP, "/") { + mainIP += "/24" + } + interfaceDisplay += fmt.Sprintf(" (%s)", mainIP) + } + } + + // Добавляем индикатор статуса (↑ для интерфейсов с IP, ↓ для без IP) + hasIPv4 := false + for _, ip := range iface.IPs { + if strings.Contains(ip, ".") { + hasIPv4 = true + break + } + } + + if hasIPv4 { + interfaceDisplay += " [↑]" + } else { + interfaceDisplay += " [↓]" + } + + interfaceOptions[i] = interfaceDisplay + + log.Printf("[INTERFACE-FORMAT] Создан вариант %d: %s", i, interfaceDisplay) + } + + data.Hostname = defaultHostname + data.NodeType = "controlplane" // or appropriate default + if len(disks) > 0 { + data.Disk = disks[0].Name // or appropriate default + } + if len(interfaces) > 0 { + data.Interface = interfaces[0].Name // or appropriate default + } + + form := tview.NewForm(). + AddDropDown("Role", []string{"controlplane", "worker"}, 0, func(option string, index int) { + data.NodeType = option + }). + AddInputField("Hostname", defaultHostname, 20, nil, func(text string) { + data.Hostname = text + }). + AddDropDown("Disk", diskOptions, 0, func(option string, index int) { + if index >= 0 && index < len(disks) { + data.Disk = disks[index].Name + } + }). + AddDropDown("Interface", interfaceOptions, 0, func(option string, index int) { + if index >= 0 && index < len(interfaces) { + data.Interface = interfaces[index].Name + } + }). + AddInputField("Virtual IP (optional)", "", 20, nil, func(text string) { + data.VIP = text + }) + + form. + AddButton("OK", func() { + // Автоматически устанавливаем сетевую конфигурацию + data.Addresses = data.SelectedNode + "/24" + data.Gateway = "192.168.1.1" + data.DNSServers = "8.8.8.8,1.1.1.1" + + p.ShowConfigConfirmation(data) + }). + AddButton("Back", func() { + p.Go(StateNodeSelect) + }). + AddButton("Cancel", func() { + p.app.Stop() + }) + + form.SetBorder(true).SetTitle("Node Configuration").SetTitleAlign(tview.AlignLeft) + p.pages.AddPage("node-config", form, true, true) + p.Go(StateNodeConfig) + p.app.SetFocus(form) +} + +// ShowVIPConfig отображает конфигурацию виртуального IP +func (p *PresenterImpl) ShowVIPConfig(data *InitData) { + form := tview.NewForm(). + AddInputField("Virtual IP (optional)", "", 20, nil, func(text string) { + data.VIP = text + }) + + form. + AddButton("Next", func() { + p.ShowConfigConfirmation(data) + }). + AddButton("Back", func() { + p.SwitchPage(p.pages, "network-config") + }). + AddButton("Cancel", func() { + p.app.Stop() + }) + + form.SetBorder(true).SetTitle("Virtual IP Configuration").SetTitleAlign(tview.AlignLeft) + p.pages.AddPage("vip-config", form, true, true) + p.SwitchPage(p.pages, "vip-config") + p.app.SetFocus(form) +} + +// ShowNetworkConfig отображает конфигурацию сети +func (p *PresenterImpl) ShowNetworkConfig(data *InitData) { + form := tview.NewForm(). + AddInputField("Addresses", "", 30, nil, func(text string) { + data.Addresses = text + }). + AddInputField("Gateway", "", 20, nil, func(text string) { + data.Gateway = text + }). + AddInputField("DNS Servers", "", 40, nil, func(text string) { + data.DNSServers = text + }) + + form. + AddButton("Next", func() { + p.ShowVIPConfig(data) + }). + AddButton("Back", func() { + p.SwitchPage(p.pages, "interface-selection") + }). + AddButton("Cancel", func() { + p.app.Stop() + }) + + form.SetBorder(true).SetTitle("Network Configuration").SetTitleAlign(tview.AlignLeft) + p.pages.AddPage("network-config", form, true, true) + p.SwitchPage(p.pages, "network-config") + p.app.SetFocus(form) +} + +// ShowProgressModal отображает модальное окно с прогрессом +func (p *PresenterImpl) ShowProgressModal(message string, task func()) { + modal := tview.NewModal(). + SetText(message) + + p.pages.AddPage("progress", modal, true, true) + p.SwitchPage(p.pages, "progress") + p.app.SetFocus(modal) + + go task() +} + +// ShowScanningModal отображает модальное окно сканирования с прогрессом +func (p *PresenterImpl) ShowScanningModal(scanFunc func(context.Context, func(int)), ctx context.Context) { + log.Printf("[FIXED-UI] Открываем модальное окно сканирования") + log.Printf("[DEBUG-SCANNING] Состояние перед Go(StateScanning): %s", p.controller.state) + + // Флаг отмены + cancelled := false + + // Создаем функцию отмены + dismissModal := func() { + if cancelled { + log.Printf("[FIXED-UI] Cancel уже выполняется, пропускаем") + return + } + cancelled = true + + log.Printf("[FIXED-UI] ===== ОТМЕНА МОДАЛЬНОГО ОКНА =====") + + // Отменяем сканирование + if p.cancelScan != nil { + log.Printf("[FIXED-UI] Отменяем сканирование...") + p.cancelScan() + p.cancelScan = nil + log.Printf("[FIXED-UI] Сканирование отменено") + } else { + log.Printf("[FIXED-UI] cancelScan не установлена") + } + + // Немедленно закрываем модальное окно и возвращаемся к предыдущему экрану + log.Printf("[FIXED-UI] НАЧИНАЕМ ПРЯМОЕ ОБНОВЛЕНИЕ UI (минуя QueueUpdateDraw)...") + + // Очищаем ссылку на модальное окно + log.Printf("[FIXED-UI] Очищаем ссылку на модальное окно...") + p.scanningModal = nil + + // Удаляем страницу сканирования + log.Printf("[FIXED-UI] Удаляем страницу 'scanning'...") + p.pages.RemovePage("scanning") + log.Printf("[FIXED-UI] Страница 'scanning' удалена") + + // Проверяем доступные страницы + log.Printf("[FIXED-UI] Проверяем доступные страницы...") + if p.pages.HasPage("cozystack-scan") { + log.Printf("[FIXED-UI] Найдена страница 'cozystack-scan', переключаемся...") + p.SwitchPage(p.pages, "cozystack-scan") + log.Printf("[FIXED-UI] Переключились на 'cozystack-scan'") + } else if p.pages.HasPage("add-node-scan") { + log.Printf("[FIXED-UI] Найдена страница 'add-node-scan', переключаемся...") + p.SwitchPage(p.pages, "add-node-scan") + log.Printf("[FIXED-UI] Переключились на 'add-node-scan'") + } else { + log.Printf("[FIXED-UI] Страницы для возврата не найдены!") + log.Printf("[FIXED-UI] Доступные страницы: %v", p.pages.GetPageNames(false)) + } + + // Сбрасываем обработчик клавиш + log.Printf("[FIXED-UI] Сбрасываем обработчик клавиш...") + p.app.SetInputCapture(nil) + log.Printf("[FIXED-UI] Обработчик клавиш сброшен") + + // Принудительно обновляем UI + log.Printf("[FIXED-UI] Принудительно обновляем UI...") + p.app.Draw() + log.Printf("[FIXED-UI] UI обновлен! Прямое обновление завершено.") + + // Добавляем небольшую задержку для гарантии обновления + log.Printf("[FIXED-UI] Добавляем задержку для гарантии обновления...") + time.Sleep(100 * time.Millisecond) + p.app.Draw() + log.Printf("[FIXED-UI] Финальное обновление UI выполнено.") + } + + // Создаем свой модальный диалог + progressText := tview.NewTextView(). + SetText("Scanning network... |\n[ ] 0%"). + SetTextAlign(tview.AlignCenter) + + cancelButton := tview.NewButton("Cancel"). + SetSelectedFunc(func() { + log.Printf("[FIXED-UI] Пользователь нажал Cancel") + dismissModal() + }) + + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.AddItem(progressText, 0, 1, false) + flex.AddItem(cancelButton, 1, 0, true) + + flex.SetBorder(true).SetTitle("Network Scanning") + flex.SetBackgroundColor(tcell.ColorBlack) + + // Сохраняем ссылку на модальное окно + p.scanningModal = flex + + // Добавляем глобальную обработку клавиш + p.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape && p.scanningModal != nil { + log.Printf("[FIXED-UI] Нажата клавиша Escape") + dismissModal() + return nil + } + return event + }) + + p.pages.AddPage("scanning", flex, true, true) + p.Go(StateScanning) + p.app.SetFocus(flex) + + go func() { + // Передаем флаг отмены в scanFunc + scanFunc(ctx, func(progress int) { + // Проверяем, не была ли отменена операция И существует ли модальное окно + if cancelled || p.scanningModal == nil { + log.Printf("[FIXED-UI] Игнорируем обновление прогресса - операция отменена или модальное окно закрыто") + return + } + + log.Printf("[FIXED-UI] Обновление прогресса: %d%%", progress) + // Обновляем UI в главном потоке + p.app.QueueUpdateDraw(func() { + if p.scanningModal != nil { + // Создаем прогресс бар + progressBar := createSimpleProgressBar(progress) + message := fmt.Sprintf("Scanning network... |\n%s %d%%", progressBar, progress) + // Обновляем текст в TextView + if flex, ok := p.scanningModal.(*tview.Flex); ok { + if progressText, ok := flex.GetItem(0).(*tview.TextView); ok { + progressText.SetText(message) + log.Printf("[FIXED-UI] UI обновлен: %s", message) + } + } + } + }) + }) + }() +} + +// ShowErrorModal отображает модальное окно с ошибкой +func (p *PresenterImpl) ShowErrorModal(message string) { + modal := tview.NewModal(). + SetText(fmt.Sprintf("Ошибка: %s", message)). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + p.pages.RemovePage("error") + }) + + p.pages.AddPage("error", modal, true, true) + p.SwitchPage(p.pages, "error") + p.app.SetFocus(modal) +} + +// ShowSuccessModal отображает модальное окно с успешным сообщением +func (p *PresenterImpl) ShowSuccessModal(message string) { + modal := tview.NewModal(). + SetText(message). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + p.app.Stop() + }) + + p.pages.AddPage("success", modal, true, true) + p.SwitchPage(p.pages, "success") + p.app.SetFocus(modal) +} + +// ShowConfigConfirmation отображает подтверждение конфигурации +func (p *PresenterImpl) ShowConfigConfirmation(data *InitData) { + config := fmt.Sprintf("Role: %s\nHostname: %s\nDisk: %s\nInterface: %s\nAddresses: %s\nGateway: %s\nDNS: %s\nVIP: %s", + data.NodeType, data.Hostname, data.Disk, data.Interface, data.Addresses, data.Gateway, data.DNSServers, data.VIP) + + modal := tview.NewModal(). + SetText(fmt.Sprintf("Confirm configuration:\n\n%s", config)). + AddButtons([]string{"OK", "Back", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonLabel { + case "OK": + p.generateMachineConfig(data) + case "Back": + p.Go(StateNodeConfig) + case "Cancel": + p.app.Stop() + } + }) + + p.pages.AddPage("confirm", modal, true, true) + p.Go(StateConfirm) + p.app.SetFocus(modal) +} + +// ShowBootstrapPrompt отображает запрос на bootstrap +func (p *PresenterImpl) ShowBootstrapPrompt(data *InitData, nodeFileName string) { + modal := tview.NewModal(). + SetText("Do you want to bootstrap etcd now?\nThis will initialize the Kubernetes cluster."). + AddButtons([]string{"Bootstrap", "Skip", "Cancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonLabel { + case "Bootstrap": + p.showBootstrapProgress() + case "Skip": + p.ShowSuccessModal("First node configured. Bootstrap can be done later.") + case "Cancel": + p.app.Stop() + } + }) + + p.pages.AddPage("bootstrap-prompt", modal, true, true) + p.SwitchPage(p.pages, "bootstrap-prompt") + p.app.SetFocus(modal) +} + +// ShowFirstNodeConfig отображает конфигурацию первой ноды +func (p *PresenterImpl) ShowFirstNodeConfig(data *InitData) { + form := tview.NewForm(). + AddInputField("Floating IP (VIP) - опционально", "", 20, nil, func(text string) { + data.FloatingIP = text + }). + AddInputField("Kubernetes Endpoint", "", 30, nil, func(text string) { + data.APIServerURL = text + }) + + form. + AddButton("Next", func() { + data.NodeType = "control-plane" + p.initializeCluster(data) + }). + AddButton("Back", func() { + p.SwitchPage(p.pages, "node-type") + }). + AddButton("Cancel", func() { + p.app.Stop() + }) + + // Устанавливаем значение по умолчанию для endpoint + if data.FloatingIP != "" { + form.GetFormItemByLabel("Kubernetes Endpoint").(*tview.InputField). + SetText(fmt.Sprintf("https://%s:6443", data.FloatingIP)) + } else if data.SelectedNode != "" { + form.GetFormItemByLabel("Kubernetes Endpoint").(*tview.InputField). + SetText(fmt.Sprintf("https://%s:6443", data.SelectedNode)) + } + + form.SetBorder(true).SetTitle("Конфигурация Первой Ноды").SetTitleAlign(tview.AlignLeft) + p.pages.AddPage("first-node-config", form, true, true) + p.SwitchPage(p.pages, "first-node-config") + p.app.SetFocus(form) +} + +// ShowNodeDetails отображает детальную информацию об узле +func (p *PresenterImpl) ShowNodeDetails(data *InitData) { + nodeInfo := data.SelectedNodeInfo + + // Создаем текстовое поле для отображения детальной информации + details := tview.NewTextView() + details.SetScrollable(true) + + var info strings.Builder + info.WriteString(fmt.Sprintf("=== Детальная Информация об Узле ===\n\n")) + info.WriteString(fmt.Sprintf("Имя: %s\n", nodeInfo.Name)) + info.WriteString(fmt.Sprintf("IP Address: %s\n", nodeInfo.IP)) + info.WriteString(fmt.Sprintf("Hostname: %s\n", nodeInfo.Hostname)) + info.WriteString(fmt.Sprintf("MAC адрес: %s\n", nodeInfo.MAC)) + info.WriteString(fmt.Sprintf("Тип: %s\n", nodeInfo.Type)) + info.WriteString(fmt.Sprintf("Статус: %s\n", map[bool]string{true: "Настроен", false: "Не настроен"}[nodeInfo.Configured])) + + // Информация о процессоре + info.WriteString("\n=== Процессор ===\n") + if len(nodeInfo.Hardware.Processors) > 0 { + for i, proc := range nodeInfo.Hardware.Processors { + info.WriteString(fmt.Sprintf("Процессор %d:\n", i+1)) + info.WriteString(fmt.Sprintf(" Производитель: %s\n", proc.Manufacturer)) + info.WriteString(fmt.Sprintf(" Модель: %s\n", proc.ProductName)) + info.WriteString(fmt.Sprintf(" Потоков: %d\n", proc.ThreadCount)) + } + } else { + info.WriteString("Информация о процессоре недоступна\n") + } + + // Информация о памяти + info.WriteString("\n=== Память ===\n") + info.WriteString(fmt.Sprintf("Общий объем: %d MiB (%d GiB)\n", nodeInfo.Hardware.Memory.Size, nodeInfo.Hardware.Memory.Size/1024)) + + // Информация о дисках + info.WriteString("\n=== Diskи ===\n") + if len(nodeInfo.Disks) > 0 { + totalSize := 0 + for i, disk := range nodeInfo.Disks { + sizeGB := disk.Size / 1024 / 1024 / 1024 + totalSize += disk.Size + info.WriteString(fmt.Sprintf("Disk %d:\n", i+1)) + info.WriteString(fmt.Sprintf(" Имя: %s\n", disk.Name)) + info.WriteString(fmt.Sprintf(" Размер: %d GB\n", sizeGB)) + info.WriteString(fmt.Sprintf(" Путь: %s\n", disk.DevPath)) + info.WriteString(fmt.Sprintf(" Модель: %s\n", disk.Model)) + info.WriteString(fmt.Sprintf(" Транспорт: %s\n", disk.Transport)) + } + info.WriteString(fmt.Sprintf("\nОбщий объем хранилища: %d GB\n", totalSize/1024/1024/1024)) + } else { + info.WriteString("Информация о дисках недоступна\n") + } + + // Информация о сетевых интерфейсах + info.WriteString("\n=== Сетевые Interfaceы ===\n") + if len(nodeInfo.Hardware.Interfaces) > 0 { + for i, iface := range nodeInfo.Hardware.Interfaces { + info.WriteString(fmt.Sprintf("Interface %d:\n", i+1)) + info.WriteString(fmt.Sprintf(" Имя: %s\n", iface.Name)) + info.WriteString(fmt.Sprintf(" MAC: %s\n", iface.MAC)) + + if len(iface.IPs) > 0 { + info.WriteString(fmt.Sprintf(" IP Addressа: %s\n", strings.Join(iface.IPs, ", "))) + } else { + info.WriteString(" IP Addressа: не настроены\n") + } + } + } else { + info.WriteString("Информация о сетевых интерфейсах недоступна\n") + } + + details.SetText(info.String()) + details.SetBorder(true).SetTitle("Детали Узла").SetTitleAlign(tview.AlignLeft) + + // Создаем кнопки + buttons := tview.NewForm(). + AddButton("Back", func() { + p.Go(StateNodeSelect) + }) + + buttons.SetButtonsAlign(tview.AlignCenter) + + // Создаем компоновку + flex := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(details, 0, 1, false). + AddItem(buttons, 3, 1, true) + + flex.SetBorder(true).SetTitle(fmt.Sprintf("Детали узла %s", nodeInfo.Hostname)).SetTitleAlign(tview.AlignLeft) + + p.pages.AddPage("node-details", flex, true, true) + p.SwitchPage(p.pages, "node-details") + p.app.SetFocus(buttons) +} + +// SwitchPage переключает страницу +func (p *PresenterImpl) SwitchPage(pages *tview.Pages, pageName string) { + log.Printf("ДИАГНОСТИКА SWITCHPAGE: Переключаемся на страницу: %s", pageName) + log.Printf("ДИАГНОСТИКА SWITCHPAGE: Доступные страницы: %v", pages.GetPageNames(false)) + log.Printf("ДИАГНОСТИКА SWITCHPAGE: Выполняем SwitchToPage...") + pages.SwitchToPage(pageName) + log.Printf("ДИАГНОСТИКА SWITCHPAGE: Переключение на %s выполнено", pageName) +} + +// debug простой отладочный метод +func (p *PresenterImpl) debug(msg string, args ...interface{}) { + if os.Getenv("DEBUG_TUI") != "" { + log.Printf("[TUI-DEBUG] "+msg, args...) + } +} + +// Вспомогательные методы + +// Удалена функция hasMACPrefix - больше не используется + +// showCozystackScanningModal отображает модальное окно сканирования для Cozystack +func (p *PresenterImpl) showCozystackScanningModal(data *InitData) { + log.Printf("[FIXED-UI] Запускаем showCozystackScanningModal") + + // Создаем контекст с возможностью отмены + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Сохраняем cancel функцию для использования при отмене + p.cancelScan = cancel + + // Флаг отмены + cancelled := false + + // Создаем функцию отмены + dismissModal := func() { + if cancelled { + log.Printf("[FIXED-UI] Cancel Cozystack уже выполняется, пропускаем") + return + } + cancelled = true + + log.Printf("[FIXED-UI] ===== ОТМЕНА МОДАЛЬНОГО ОКНА COZYSTACK =====") + + if p.cancelScan != nil { + log.Printf("[FIXED-UI] Отменяем сканирование Cozystack...") + p.cancelScan() + p.cancelScan = nil + log.Printf("[FIXED-UI] Сканирование Cozystack отменено") + } else { + log.Printf("[FIXED-UI] cancelScan Cozystack не установлена") + } + + // Немедленно закрываем модальное окно и возвращаемся к предыдущему экрану + log.Printf("[FIXED-UI] НАЧИНАЕМ ПРЯМОЕ ОБНОВЛЕНИЕ UI COZYSTACK...") + + // Очищаем ссылку на модальное окно + log.Printf("[FIXED-UI] Очищаем ссылку на модальное окно Cozystack...") + p.scanningModal = nil + + // Удаляем страницу сканирования + log.Printf("[FIXED-UI] Удаляем страницу 'scanning' Cozystack...") + p.pages.RemovePage("scanning") + log.Printf("[FIXED-UI] Страница 'scanning' Cozystack удалена") + + // Переключаемся на страницу Cozystack + log.Printf("[FIXED-UI] Переключаемся на страницу 'cozystack-scan'...") + p.SwitchPage(p.pages, "cozystack-scan") + log.Printf("[FIXED-UI] Переключились на 'cozystack-scan'") + + // Сбрасываем обработчик клавиш + log.Printf("[FIXED-UI] Сбрасываем обработчик клавиш Cozystack...") + p.app.SetInputCapture(nil) + log.Printf("[FIXED-UI] Обработчик клавиш Cozystack сброшен") + + // Принудительно обновляем UI + log.Printf("[FIXED-UI] Принудительно обновляем UI Cozystack...") + p.app.Draw() + log.Printf("[FIXED-UI] UI Cozystack обновлен! Прямое обновление завершено.") + + // Добавляем небольшую задержку для гарантии обновления + log.Printf("[FIXED-UI] Добавляем задержку для гарантии обновления Cozystack...") + time.Sleep(100 * time.Millisecond) + p.app.Draw() + log.Printf("[FIXED-UI] Финальное обновление UI Cozystack выполнено.") + } + + // Создаем свой модальный диалог + progressText := tview.NewTextView(). + SetText("Scanning network... |\n[ ] 0%"). + SetTextAlign(tview.AlignCenter) + + cancelButton := tview.NewButton("Cancel"). + SetSelectedFunc(func() { + log.Printf("[FIXED-UI] Пользователь нажал Cancel в Cozystack") + dismissModal() + }) + + flex := tview.NewFlex().SetDirection(tview.FlexRow) + flex.AddItem(progressText, 0, 1, false) + flex.AddItem(cancelButton, 1, 0, true) + + flex.SetBorder(true).SetTitle("Network Scanning") + flex.SetBackgroundColor(tcell.ColorBlack) + + // Сохраняем ссылку на модальное окно + p.scanningModal = flex + + // Добавляем глобальную обработку клавиш + p.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape && p.scanningModal != nil { + log.Printf("[FIXED-UI] Нажата клавиша Escape в Cozystack") + dismissModal() + return nil + } + return event + }) + + p.pages.AddPage("scanning", flex, true, true) + p.Go(StateScanning) + p.app.SetFocus(flex) + + go func() { + log.Printf("[FIXED-UI] Запускаем сканирование в Cozystack") + + // Получаем сканер от wizard + wizard := p.wizard + scanner := wizard.GetScanner() + + // Запускаем сканирование + nodes, err := scanner.ScanNetworkWithProgress(ctx, data.NetworkToScan, func(progress int) { + // Проверяем, не была ли отменена операция И существует ли модальное окно + if cancelled || p.scanningModal == nil { + log.Printf("[FIXED-UI] Игнорируем обновление прогресса Cozystack - операция отменена или модальное окно закрыто") + return + } + + log.Printf("[FIXED-UI] Обновление прогресса Cozystack: %d%%", progress) + // Обновляем UI в главном потоке + p.app.QueueUpdateDraw(func() { + if p.scanningModal != nil { + // Создаем прогресс бар + progressBar := createSimpleProgressBar(progress) + message := fmt.Sprintf("Scanning network... |\n%s %d%%", progressBar, progress) + // Обновляем текст в TextView + if flex, ok := p.scanningModal.(*tview.Flex); ok { + if progressText, ok := flex.GetItem(0).(*tview.TextView); ok { + progressText.SetText(message) + log.Printf("[FIXED-UI] UI Cozystack обновлен: %s", message) + } + } + } + }) + }) + + // Очищаем cancel функцию после завершения + p.cancelScan = nil + + // Проверяем, не была ли отменена операция + if cancelled { + log.Printf("[FIXED-UI] Сканирование Cozystack было отменено") + return + } + + log.Printf("[FIXED-UI] Сканирование Cozystack завершено, найдено %d нод", len(nodes)) + + if err != nil { + log.Printf("[FIXED-UI] Ошибка сканирования Cozystack: %v", err) + p.app.QueueUpdateDraw(func() { + p.scanningModal = nil + p.ShowErrorModal(fmt.Sprintf("Ошибка сканирования: %v", err)) + }) + return + } + + // Сохраняем результаты + data.DiscoveredNodes = nodes + + // Показываем результаты + p.app.QueueUpdateDraw(func() { + p.scanningModal = nil + p.pages.RemovePage("scanning") + if len(nodes) > 0 { + p.ShowNodeSelection(data, "Select First Control Plane Node") + } else { + p.ShowErrorModal("В сети не найдено нод Talos") + } + }) + }() +} + +// performNetworkScan выполняет сканирование сети +func (p *PresenterImpl) performNetworkScan(data *InitData, updateProgress func(int)) { + log.Printf("[FIXED-UI] Начинаем performNetworkScan для сети: %s", data.NetworkToScan) + log.Printf("[FIXED-UI] Получен updateProgress callback: %v", updateProgress != nil) + log.Printf("[DEBUG-PERFORM-SCAN] Состояние перед сканированием: %s", p.controller.state) + + // Получаем сканер от wizard + wizard := p.wizard + scanner := wizard.GetScanner() + + // Создаем контекст с возможностью отмены + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Сохраняем cancel функцию для использования при отмене + p.cancelScan = cancel + + log.Printf("[FIXED-UI] Запускаем сканирование с отменой...") + + // Запускаем сканирование + nodes, err := scanner.ScanNetworkWithProgress(ctx, data.NetworkToScan, func(progress int) { + // Проверяем, не была ли отменена операция И существует ли модальное окно + if p.scanningModal == nil { + log.Printf("[FIXED-UI] Игнорируем обновление прогресса в performNetworkScan - модальное окно закрыто") + return + } + + log.Printf("[FIXED-UI] Обновление прогресса: %d%%", progress) + // Обновляем UI в главном потоке + p.app.QueueUpdateDraw(func() { + // Обновляем модальное окно с новым прогрессом + if p.scanningModal != nil { + // Создаем прогресс бар + progressBar := createSimpleProgressBar(progress) + message := fmt.Sprintf("Scanning network... |\n%s %d%%", progressBar, progress) + // Обновляем текст в TextView + if flex, ok := p.scanningModal.(*tview.Flex); ok { + if progressText, ok := flex.GetItem(0).(*tview.TextView); ok { + progressText.SetText(message) + log.Printf("[FIXED-UI] UI обновлен: %s", message) + } + } + } + }) + }) + + // Очищаем cancel функцию после завершения + p.cancelScan = nil + + if err != nil { + log.Printf("[FIXED-UI] Ошибка сканирования: %v", err) + p.app.QueueUpdateDraw(func() { + p.scanningModal = nil + p.ShowErrorModal(fmt.Sprintf("Ошибка сканирования: %v", err)) + }) + return + } + + log.Printf("[FIXED-UI] Сканирование завершено, найдено %d нод", len(nodes)) + log.Printf("[DEBUG-PERFORM-SCAN] Состояние после сканирования перед ShowNodeSelection: %s", p.controller.state) + + // Сохраняем результаты + data.DiscoveredNodes = nodes + + // Показываем результаты + p.app.QueueUpdateDraw(func() { + p.scanningModal = nil + p.pages.RemovePage("scanning") + if len(nodes) > 0 { + log.Printf("[DEBUG-PERFORM-SCAN] Вызываем ShowNodeSelection") + p.ShowNodeSelection(data, "Select Node to Add") + } else { + p.ShowErrorModal("В сети не найдено нод Talos") + } + }) +} + +// runScanningWithProgress запускает сканирование с отображением прогресса +func (p *PresenterImpl) runScanningWithProgress(scanFunc func(context.Context, func(int)), ctx context.Context) { + log.Printf("[FIXED-UI] Запускаем runScanningWithProgress") + + // Функция обновления прогресса в UI + updateProgress := func(progress int) { + log.Printf("[FIXED-UI] Обновление прогресса: %d%%", progress) + // Обновляем UI в главном потоке + p.app.QueueUpdateDraw(func() { + if p.scanningModal != nil { + // Создаем прогресс бар + progressBar := createSimpleProgressBar(progress) + message := fmt.Sprintf("Scanning network... |\n%s %d%%", progressBar, progress) + // Обновляем текст в TextView + if flex, ok := p.scanningModal.(*tview.Flex); ok { + if progressText, ok := flex.GetItem(0).(*tview.TextView); ok { + progressText.SetText(message) + log.Printf("[FIXED-UI] UI обновлен: %s", message) + } + } + } + }) + } + + // Запускаем сканирование + log.Printf("[FIXED-UI] Выполняем scanFunc...") + scanFunc(ctx, updateProgress) + + log.Printf("[FIXED-UI] Сканирование завершено") + + // Принудительно обновляем UI после завершения + p.app.QueueUpdateDraw(func() { + log.Printf("[FIXED-UI] Принудительное обновление UI после завершения") + if p.scanningModal != nil { + p.app.Draw() + log.Printf("[FIXED-UI] UI принудительно обновлен") + } + }) +} + +// createProgressBar создает визуальный прогресс бар +func (p *PresenterImpl) createProgressBar(progress int) string { + const width = 40 + filled := (progress * width) / 100 + + var bar []byte + bar = append(bar, '[') + for i := 0; i < width; i++ { + if i < filled { + bar = append(bar, '=') + } else if i == filled { + bar = append(bar, '>') + } else { + bar = append(bar, ' ') + } + } + bar = append(bar, ']') + return string(bar) +} + +// initializeCluster инициализирует кластер +func (p *PresenterImpl) initializeCluster(data *InitData) { + // Валидация входных данных + if data.ClusterName == "" { + p.ShowErrorModal("Пожалуйста, введите имя кластера") + return + } + + if data.APIServerURL == "" { + p.ShowErrorModal("Пожалуйста, укажите Kubernetes Endpoint") + return + } + + // Устанавливаем значения по умолчанию в зависимости от preset'а + if data.PodSubnets == "" { + if data.Preset == "cozystack" { + data.PodSubnets = "10.244.0.0/16" + } else { + data.PodSubnets = "10.244.0.0/16" + } + } + + if data.ServiceSubnets == "" { + if data.Preset == "cozystack" { + data.ServiceSubnets = "10.96.0.0/16" + } else { + data.ServiceSubnets = "10.96.0.0/16" + } + } + + if data.AdvertisedSubnets == "" { + data.AdvertisedSubnets = "192.168.0.0/24" + } + + // Устанавливаем дополнительные значения для cozystack + if data.Preset == "cozystack" { + if data.ClusterDomain == "" { + data.ClusterDomain = "cozy.local" + } + if data.Image == "" { + data.Image = "ghcr.io/cozystack/cozystack/talos:v1.10.5" + } + } + + p.ShowProgressModal(fmt.Sprintf("Инициализация %s кластера...", data.Preset), func() { + // Генерируем конфигурации + if err := GenerateFromTUI(data); err != nil { + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal(fmt.Sprintf("Ошибка генерации: %v", err)) + }) + return + } + + // Показываем успешное завершение + p.app.QueueUpdateDraw(func() { + p.ShowSuccessModal(fmt.Sprintf("%s кластер успешно инициализирован!\n\nСозданные файлы:\n- talosconfig\n- secrets.yaml\n- Chart.yaml\n- values.yaml\n- templates/\n\nNext steps:\n1. Проверьте созданные файлы\n2. Запустите 'helm install' для развертывания\n3. Use 'kubectl' to manage the cluster", + strings.Title(data.Preset))) + }) + }) +} + +// initializeGenericCluster инициализирует generic кластер +func (p *PresenterImpl) initializeGenericCluster(data *InitData) { + // Валидация входных данных + if data.ClusterName == "" { + p.ShowErrorModal("Пожалуйста, введите имя кластера") + return + } + + if data.APIServerURL == "" { + p.ShowErrorModal("Пожалуйста, укажите Kubernetes Endpoint") + return + } + + // Устанавливаем значения по умолчанию для generic preset + if data.PodSubnets == "" { + data.PodSubnets = "10.244.0.0/16" + } + if data.ServiceSubnets == "" { + data.ServiceSubnets = "10.96.0.0/16" + } + if data.AdvertisedSubnets == "" { + data.AdvertisedSubnets = "192.168.0.0/24" + } + + p.ShowProgressModal("Инициализация generic кластера...", func() { + // Генерируем конфигурации + if err := GenerateFromTUI(data); err != nil { + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal(fmt.Sprintf("Ошибка генерации: %v", err)) + }) + return + } + + // Показываем успешное завершение + p.app.QueueUpdateDraw(func() { + p.ShowSuccessModal("Generic кластер успешно инициализирован!\n\nСозданные файлы:\n- talosconfig\n- secrets.yaml\n- Chart.yaml\n- values.yaml\n- templates/") + }) + }) +} + +// generateMachineConfig генерирует конфигурацию машины +func (p *PresenterImpl) generateMachineConfig(data *InitData) { + log.Printf("[MACHINE-CONFIG] Начинаем генерацию машинной конфигурации для ноды: %s", data.SelectedNode) + + p.ShowProgressModal("Generating machine config...", func() { + log.Printf("[MACHINE-CONFIG] Запуск генерации машинной конфигурации...") + + // Валидация обязательных данных + if data.SelectedNode == "" { + log.Printf("[MACHINE-CONFIG] Ошибка: не выбрана нода") + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal("Не выбрана нода для генерации конфигурации") + }) + return + } + + if data.Hostname == "" { + log.Printf("[MACHINE-CONFIG] Ошибка: не указано имя хоста") + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal("Не указано имя хоста для ноды") + }) + return + } + + if data.Disk == "" { + log.Printf("[MACHINE-CONFIG] Ошибка: не выбран диск") + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal("Не выбран диск для установки") + }) + return + } + + if data.Interface == "" { + log.Printf("[MACHINE-CONFIG] Ошибка: не выбран сетевой интерфейс") + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal("Не выбран сетевой интерфейс") + }) + return + } + + log.Printf("[MACHINE-CONFIG] Все необходимые данные валидны") + log.Printf("[MACHINE-CONFIG] Параметры:") + log.Printf("[MACHINE-CONFIG] - Node: %s", data.SelectedNode) + log.Printf("[MACHINE-CONFIG] - Hostname: %s", data.Hostname) + log.Printf("[MACHINE-CONFIG] - NodeType: %s", data.NodeType) + log.Printf("[MACHINE-CONFIG] - Disk: %s", data.Disk) + log.Printf("[MACHINE-CONFIG] - Interface: %s", data.Interface) + log.Printf("[MACHINE-CONFIG] - Addresses: %s", data.Addresses) + log.Printf("[MACHINE-CONFIG] - Gateway: %s", data.Gateway) + log.Printf("[MACHINE-CONFIG] - DNS: %s", data.DNSServers) + log.Printf("[MACHINE-CONFIG] - VIP: %s", data.VIP) + + // Подготавливаем values для шаблона + nodeValues := map[string]interface{}{ + "nodeHostname": data.Hostname, + "nodeDisk": data.Disk, + "nodeImage": p.getDefaultImageForPreset(data.Preset), + } + + // Добавляем сетевую конфигурацию + if data.Addresses != "" { + // Создаем интерфейс конфигурацию + nodeInterface := map[string]interface{}{ + "interface": data.Interface, + "addresses": []string{data.Addresses}, + } + + if data.Gateway != "" { + nodeInterface["routes"] = []map[string]interface{}{ + { + "network": "0.0.0.0/0", + "gateway": data.Gateway, + }, + } + } + + if data.VIP != "" && data.NodeType == "controlplane" { + nodeInterface["vip"] = map[string]interface{}{ + "ip": data.VIP, + } + } + + nodeValues["nodeInterfaces"] = nodeInterface + } + + // Добавляем DNS Servers + if data.DNSServers != "" { + dns := strings.Split(data.DNSServers, ",") + for i, server := range dns { + dns[i] = strings.TrimSpace(server) + } + nodeValues["nodeNameservers"] = map[string]interface{}{"servers": dns} + } + + // Выбираем шаблон на основе NodeType + var templateFile string + if data.NodeType == "controlplane" { + templateFile = "templates/controlplane.yaml" + } else if data.NodeType == "worker" { + templateFile = "templates/worker.yaml" + } else { + templateFile = "templates/node.yaml" // fallback + } + + // Создаем опции для engine.Render + opts := engine.Options{ + ValueFiles: []string{"values.yaml"}, // Используем существующие values.yaml + WithSecrets: "secrets.yaml", + TemplateFiles: []string{templateFile}, + Offline: true, // Не используем lookup функции + KubernetesVersion: constants.DefaultKubernetesVersion, + } + + // Сериализуем nodeInterfaces и nodeNameservers в JSON для opts.JsonValues + if nodeValues["nodeInterfaces"] != nil { + jsonData, err := json.Marshal(nodeValues["nodeInterfaces"]) + if err == nil { + opts.JsonValues = append(opts.JsonValues, string(jsonData)) + } else { + log.Printf("[MACHINE-CONFIG] Ошибка сериализации nodeInterfaces: %v", err) + } + } + + if nodeValues["nodeNameservers"] != nil { + jsonData, err := json.Marshal(nodeValues["nodeNameservers"]) + if err == nil { + opts.JsonValues = append(opts.JsonValues, string(jsonData)) + } else { + log.Printf("[MACHINE-CONFIG] Ошибка сериализации nodeNameservers: %v", err) + } + } + + // Определяем тип машины для шаблона + var machineType string + if data.NodeType == "controlplane" { + machineType = "controlplane" + } else { + machineType = "worker" + } + + // Добавляем machineType в values через set + opts.Values = append(opts.Values, fmt.Sprintf("machineType=%s", machineType)) + + // Добавляем node-specific values через set + if nodeValues["nodeHostname"] != nil { + opts.Values = append(opts.Values, fmt.Sprintf("nodeHostname=%s", nodeValues["nodeHostname"])) + } + if nodeValues["nodeDisk"] != nil { + opts.Values = append(opts.Values, fmt.Sprintf("nodeDisk=%s", nodeValues["nodeDisk"])) + } + if nodeValues["nodeImage"] != nil { + opts.Values = append(opts.Values, fmt.Sprintf("nodeImage=%s", nodeValues["nodeImage"])) + } + + // Выполняем рендеринг через engine.Render + ctx := context.Background() + configBytes, err := engine.Render(ctx, nil, opts) + if err != nil { + log.Printf("[MACHINE-CONFIG] Ошибка рендеринга шаблона: %v", err) + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal(fmt.Sprintf("Ошибка генерации конфигурации: %v", err)) + }) + return + } + + // Генерируем имя файла с автоинкрементом + configFilename, err := generateNodeFileName("") + if err != nil { + log.Printf("[MACHINE-CONFIG] Ошибка генерации имени файла: %v", err) + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal(fmt.Sprintf("Ошибка генерации имени файла: %v", err)) + }) + return + } + + // Сохраняем конфигурацию в файл + if err := os.WriteFile(configFilename, configBytes, 0o644); err != nil { + log.Printf("[MACHINE-CONFIG] Ошибка сохранения файла конфигурации: %v", err) + p.app.QueueUpdateDraw(func() { + p.ShowErrorModal(fmt.Sprintf("Ошибка сохранения конфигурации: %v", err)) + }) + return + } + + log.Printf("[MACHINE-CONFIG] Машинная конфигурация сохранена в файл: %s", configFilename) + + // Обновляем данные + data.MachineConfig = string(configBytes) + + log.Printf("[MACHINE-CONFIG] Генерация машинной конфигурации завершена успешно") + + // Показываем результат + p.app.QueueUpdateDraw(func() { + p.ShowSuccessModal(fmt.Sprintf("Машинная конфигурация успешно создана!\n\nФайл: %s\nНода: %s (%s)\nТип: %s\n\nNext steps:\n1. Установите Talos на ноду используя этот файл\n2. Примените конфигурацию: talosctl apply-config -n %s -f %s", + configFilename, data.Hostname, data.SelectedNode, data.NodeType, data.SelectedNode, configFilename)) + }) + }) +} + +// showBootstrapProgress отображает прогресс bootstrap +func (p *PresenterImpl) showBootstrapProgress() { + modal := tview.NewModal(). + SetText("Bootstrapping etcd...\nPlease wait") + + p.pages.AddPage("bootstrapping", modal, true, true) + p.SwitchPage(p.pages, "bootstrapping") + p.app.SetFocus(modal) + + go func() { + // Здесь будет логика bootstrap + p.app.QueueUpdateDraw(func() { + p.pages.RemovePage("bootstrapping") + p.ShowSuccessModal("Cluster bootstrapped successfully!\n\nNext steps:\n1. Check 'kubeconfig' file\n2. Use 'kubectl' to manage cluster") + }) + }() +} + +// createSimpleProgressBar создает простой текстовый прогресс бар +func createSimpleProgressBar(progress int) string { + const width = 30 + filled := (progress * width) / 100 + + var bar []byte + bar = append(bar, '[') + for i := 0; i < width; i++ { + if i < filled { + bar = append(bar, '=') + } else { + bar = append(bar, ' ') + } + } + bar = append(bar, ']') + return string(bar) +} + +// generateNodeMachineConfig генерирует машинную конфигурацию для конкретной ноды +func (p *PresenterImpl) generateNodeMachineConfig(data *InitData) (string, error) { + log.Printf("[NODE-CONFIG] Генерация машинной конфигурации для ноды %s (%s)", data.Hostname, data.SelectedNode) + + // Создаем базовую машинную конфигурацию + config := fmt.Sprintf(`# Machine Configuration for %s +apiVersion: v1alpha1 +kind: MachineConfig +metadata: + name: %s + namespace: %s +spec: + # Machine network configuration + network: + hostname: %s + interfaces: + - interface: %s + addresses: + - %s + dhcp: false + routes: + - gateway: %s + destination: 0.0.0.0/0 + dns: + servers: + - %s + vip: + ip: %s + + # Machine type + machineType: %s + + # Install configuration + install: + disk: %s + image: %s + + # Additional machine configuration + controlPlane: + endpoint: %s +`, + data.Hostname, data.Hostname, data.ClusterName, + data.Hostname, data.Interface, data.Addresses, + data.Gateway, data.DNSServers, data.VIP, + data.NodeType, data.Disk, + p.getDefaultImageForPreset(data.Preset), + data.APIServerURL) + + // Добавляем специфичные настройки для разных типов нод + if data.NodeType == "controlplane" { + controlPlaneConfig := fmt.Sprintf(` + # Control plane specific configuration + kubelet: + extraArgs: + node-labels: node-role.kubernetes.io/control-plane + + apiServer: + certSANs: + - 127.0.0.1 + - %s +`, data.VIP) + config = config + controlPlaneConfig + } else { + workerConfig := ` + # Worker node specific configuration + kubelet: + extraArgs: + node-labels: node-role.kubernetes.io/worker +` + config = config + workerConfig + } + + // Добавляем специфичные настройки для Cozystack + if data.Preset == "cozystack" { + cozystackConfig := ` + # Cozystack specific configuration + kernel: + modules: + - name: br_netfilter + - name: overlay + sysctl: + net.ipv4.ip_forward: 1 + net.bridge.bridge-nf-call-iptables: 1 + net.bridge.bridge-nf-call-ip6tables: 1 +` + config = config + cozystackConfig + + if data.NrHugepages > 0 { + hugepagesConfig := fmt.Sprintf(` + # Hugepages configuration + system: + environment: + NR_HUGEPAGES: "%d" +`, data.NrHugepages) + config = config + hugepagesConfig + } + } + + log.Printf("[NODE-CONFIG] Машинная конфигурация создана, размер: %d символов", len(config)) + return config, nil +} + +// getDefaultImageForPreset возвращает образ Talos по умолчанию для preset'а +func (p *PresenterImpl) getDefaultImageForPreset(preset string) string { + switch preset { + case "cozystack": + return "ghcr.io/cozystack/cozystack/talos:v1.10.5" + case "generic": + return "ghcr.io/siderolabs/talos:v1.10.5" + default: + return "ghcr.io/siderolabs/talos:latest" + } +} diff --git a/internal/pkg/ui/initwizard/processor.go b/internal/pkg/ui/initwizard/processor.go new file mode 100644 index 0000000..944bf7a --- /dev/null +++ b/internal/pkg/ui/initwizard/processor.go @@ -0,0 +1,366 @@ +package initwizard + +import ( + "fmt" + "sort" + "strings" +) + +// DataProcessorImpl реализует интерфейс DataProcessor +type DataProcessorImpl struct{} + +// NewDataProcessor создает новый экземпляр процессора данных +func NewDataProcessor() DataProcessor { + return &DataProcessorImpl{} +} + +// FilterAndSortNodes фильтрует и сортирует список нод +func (p *DataProcessorImpl) FilterAndSortNodes(nodes []NodeInfo) []NodeInfo { + if len(nodes) == 0 { + return nodes + } + + // Фильтруем ноды + var filtered []NodeInfo + for _, node := range nodes { + // Исключаем ноды без IP адреса + if node.IP == "" { + continue + } + + // Исключаем дубликаты по MAC адресу + if node.MAC != "" { + isDuplicate := false + for _, existing := range filtered { + if existing.MAC == node.MAC { + isDuplicate = true + break + } + } + if isDuplicate { + continue + } + } + + filtered = append(filtered, node) + } + + // Сортируем ноды: сначала controlplane, затем worker + sort.Slice(filtered, func(i, j int) bool { + // Приоритет controlplane над worker + if filtered[i].Type != filtered[j].Type { + if filtered[i].Type == "controlplane" { + return true + } + if filtered[j].Type == "controlplane" { + return false + } + } + // Если типы одинаковые, сортируем по IP + return filtered[i].IP < filtered[j].IP + }) + + return filtered +} + +// ExtractHardwareInfo извлекает и обрабатывает информацию об оборудовании +func (p *DataProcessorImpl) ExtractHardwareInfo(ip string) (Hardware, error) { + // Этот метод может быть использован для дополнительной обработки + // информации об оборудовании, если потребуется + return Hardware{}, fmt.Errorf("метод ExtractHardwareInfo не реализован") +} + +// ProcessScanResults обрабатывает результаты сканирования сети +func (p *DataProcessorImpl) ProcessScanResults(results []NodeInfo) []NodeInfo { + if len(results) == 0 { + return results + } + + // Удаляем дубликаты + processed := p.RemoveDuplicatesByMAC(results) + + // Фильтруем и сортируем + processed = p.FilterAndSortNodes(processed) + + return processed +} + +// CalculateResourceStats вычисляет статистику ресурсов ноды +func (p *DataProcessorImpl) CalculateResourceStats(node NodeInfo) (cpu, ram, disks int) { + // Подсчет CPU + cpu = node.CPU + if cpu == 0 { + // Пытаемся подсчитать из информации об оборудовании + for _, processor := range node.Hardware.Processors { + cpu += processor.ThreadCount + } + } + + // Подсчет RAM + ram = node.RAM + if ram == 0 { + // Подсчитываем из информации об оборудовании + ram = node.Hardware.Memory.Size / 1024 // MiB to GiB + } + + // Подсчет дисков + disks = len(node.Disks) + if disks == 0 { + disks = len(node.Hardware.Blockdevices) + } + + return cpu, ram, disks +} + +// RemoveDuplicatesByMAC удаляет дубликаты нод по MAC адресу +func (p *DataProcessorImpl) RemoveDuplicatesByMAC(nodes []NodeInfo) []NodeInfo { + if len(nodes) == 0 { + return nodes + } + + seen := make(map[string]bool) + var unique []NodeInfo + + for _, node := range nodes { + mac := node.MAC + if mac == "" { + // Если MAC пустой, добавляем по IP как fallback + unique = append(unique, node) + continue + } + + if !seen[mac] { + seen[mac] = true + unique = append(unique, node) + } + } + + return unique +} + +// FilterNodesByRole фильтрует ноды по роли +func (p *DataProcessorImpl) FilterNodesByRole(nodes []NodeInfo, role string) []NodeInfo { + var filtered []NodeInfo + + for _, node := range nodes { + if node.Type == role { + filtered = append(filtered, node) + } + } + + return filtered +} + +// FilterNodesByManufacturer фильтрует ноды по производителю +func (p *DataProcessorImpl) FilterNodesByManufacturer(nodes []NodeInfo, manufacturer string) []NodeInfo { + var filtered []NodeInfo + + for _, node := range nodes { + if strings.Contains(strings.ToLower(node.Manufacturer), strings.ToLower(manufacturer)) { + filtered = append(filtered, node) + } + } + + return filtered +} + +// SortNodesByCPU сортирует ноды по количеству CPU ядер +func (p *DataProcessorImpl) SortNodesByCPU(nodes []NodeInfo, ascending bool) []NodeInfo { + sorted := make([]NodeInfo, len(nodes)) + copy(sorted, nodes) + + sort.Slice(sorted, func(i, j int) bool { + cpuI, _, _ := p.CalculateResourceStats(sorted[i]) + cpuJ, _, _ := p.CalculateResourceStats(sorted[j]) + + if ascending { + return cpuI < cpuJ + } + return cpuI > cpuJ + }) + + return sorted +} + +// SortNodesByRAM сортирует ноды по объему RAM +func (p *DataProcessorImpl) SortNodesByRAM(nodes []NodeInfo, ascending bool) []NodeInfo { + sorted := make([]NodeInfo, len(nodes)) + copy(sorted, nodes) + + sort.Slice(sorted, func(i, j int) bool { + _, ramI, _ := p.CalculateResourceStats(sorted[i]) + _, ramJ, _ := p.CalculateResourceStats(sorted[j]) + + if ascending { + return ramI < ramJ + } + return ramI > ramJ + }) + + return sorted +} + +// SortNodesByDisks сортирует ноды по количеству дисков +func (p *DataProcessorImpl) SortNodesByDisks(nodes []NodeInfo, ascending bool) []NodeInfo { + sorted := make([]NodeInfo, len(nodes)) + copy(sorted, nodes) + + sort.Slice(sorted, func(i, j int) bool { + _, _, disksI := p.CalculateResourceStats(sorted[i]) + _, _, disksJ := p.CalculateResourceStats(sorted[j]) + + if ascending { + return disksI < disksJ + } + return disksI > disksJ + }) + + return sorted +} + +// GetNodeSummary возвращает сводную информацию о ноде +func (p *DataProcessorImpl) GetNodeSummary(node NodeInfo) string { + cpu, ram, disks := p.CalculateResourceStats(node) + + summary := fmt.Sprintf("IP: %s", node.IP) + + if node.Hostname != "" && node.Hostname != node.IP { + summary += fmt.Sprintf(", Hostname: %s", node.Hostname) + } + + if node.Manufacturer != "" { + summary += fmt.Sprintf(", CPU: %s", node.Manufacturer) + } + + if cpu > 0 { + summary += fmt.Sprintf(" %d cores", cpu) + } + + if ram > 0 { + summary += fmt.Sprintf(", RAM: %d GB", ram) + } + + if disks > 0 { + summary += fmt.Sprintf(", Disks: %d", disks) + } + + if node.Type != "" { + summary += fmt.Sprintf(", Role: %s", node.Type) + } + + return summary +} + +// GetClusterSummary возвращает сводную информацию о кластере нод +func (p *DataProcessorImpl) GetClusterSummary(nodes []NodeInfo) string { + if len(nodes) == 0 { + return "Нет доступных нод" + } + + controlplaneCount := 0 + workerCount := 0 + totalCPU := 0 + totalRAM := 0 + totalDisks := 0 + + for _, node := range nodes { + if node.Type == "controlplane" { + controlplaneCount++ + } else if node.Type == "worker" { + workerCount++ + } + + cpu, ram, disks := p.CalculateResourceStats(node) + totalCPU += cpu + totalRAM += ram + totalDisks += disks + } + + summary := fmt.Sprintf("Всего нод: %d", len(nodes)) + if controlplaneCount > 0 { + summary += fmt.Sprintf(", Control Plane: %d", controlplaneCount) + } + if workerCount > 0 { + summary += fmt.Sprintf(", Worker: %d", workerCount) + } + summary += fmt.Sprintf(", CPU: %d cores, RAM: %d GB, Disks: %d", totalCPU, totalRAM, totalDisks) + + return summary +} + +// ValidateNodeCompatibility проверяет совместимость нод для кластера +func (p *DataProcessorImpl) ValidateNodeCompatibility(nodes []NodeInfo) error { + if len(nodes) == 0 { + return fmt.Errorf("нет нод для проверки совместимости") + } + + // Проверяем наличие хотя бы одной controlplane ноды + hasControlplane := false + for _, node := range nodes { + if node.Type == "controlplane" { + hasControlplane = true + break + } + } + + if !hasControlplane { + return fmt.Errorf("в кластере должна быть хотя бы одна controlplane нода") + } + + // Проверяем уникальность MAC адресов + macSet := make(map[string]bool) + for _, node := range nodes { + if node.MAC != "" { + if macSet[node.MAC] { + return fmt.Errorf("обнаружен дубликат MAC адреса: %s", node.MAC) + } + macSet[node.MAC] = true + } + } + + return nil +} + +// GroupNodesByType группирует ноды по типу +func (p *DataProcessorImpl) GroupNodesByType(nodes []NodeInfo) map[string][]NodeInfo { + groups := make(map[string][]NodeInfo) + + for _, node := range nodes { + nodeType := node.Type + if nodeType == "" { + nodeType = "unknown" + } + groups[nodeType] = append(groups[nodeType], node) + } + + return groups +} + +// FindBestControlplaneNode находит лучшую ноду для роли controlplane +func (p *DataProcessorImpl) FindBestControlplaneNode(nodes []NodeInfo) (NodeInfo, error) { + var controlplaneNodes []NodeInfo + + for _, node := range nodes { + if node.Type == "controlplane" { + controlplaneNodes = append(controlplaneNodes, node) + } + } + + if len(controlplaneNodes) == 0 { + return NodeInfo{}, fmt.Errorf("нет доступных controlplane нод") + } + + // Сортируем по ресурсам (больше CPU и RAM = лучше) + sort.Slice(controlplaneNodes, func(i, j int) bool { + cpuI, ramI, _ := p.CalculateResourceStats(controlplaneNodes[i]) + cpuJ, ramJ, _ := p.CalculateResourceStats(controlplaneNodes[j]) + + // Сравниваем по CPU, затем по RAM + if cpuI != cpuJ { + return cpuI > cpuJ + } + return ramI > ramJ + }) + + return controlplaneNodes[0], nil +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/scanner.go b/internal/pkg/ui/initwizard/scanner.go new file mode 100644 index 0000000..8ece433 --- /dev/null +++ b/internal/pkg/ui/initwizard/scanner.go @@ -0,0 +1,1466 @@ +package initwizard + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net" + "os/exec" + "regexp" + "strings" + "sync" + "time" +) + +// NodeCommandExecutor интерфейс для выполнения команд на узлах +type NodeCommandExecutor interface { + ExecuteNodeCommand(ctx context.Context, nodeIP, command string) (string, error) +} + +// NetworkScannerImpl implements the NetworkScanner interface +type NetworkScannerImpl struct { + timeout time.Duration + commandExecutor NodeCommandExecutor +} + +// NewNetworkScanner creates a new network scanner instance +func NewNetworkScanner(commandExecutor NodeCommandExecutor) NetworkScanner { + return &NetworkScannerImpl{ + timeout: 2 * time.Second, + commandExecutor: commandExecutor, + } +} + +// ScanNetwork сканирует сеть для обнаружения нод Talos +func (s *NetworkScannerImpl) ScanNetwork(ctx context.Context, cidr string) ([]NodeInfo, error) { + log.Printf("[DIAGNOSTIC] Starting network scan for CIDR: %s", cidr) + start := time.Now() + log.Printf("[DIAGNOSTIC] Контекст отмены: %v", ctx.Err()) + + // Получаем список IP адресов с открытым портом 50000 + log.Printf("[DIAGNOSTIC] Начинаем сканирование nmap...") + nmapStart := time.Now() + ips, err := s.scanForTalOSNodes(ctx, cidr) + nmapDuration := time.Since(nmapStart) + log.Printf("[DIAGNOSTIC] nmap завершен за %v, найдено %d IP адресов: %v", nmapDuration, len(ips), ips) + if err != nil { + return nil, fmt.Errorf("сканирование сети не удалось: %v", err) + } + + if len(ips) == 0 { + return nil, fmt.Errorf("в сети не найдено нод с открытым портом 50000") + } + + // Собираем информацию о найденных нодах + nodes, err := s.ParallelScan(ctx, ips) + if err != nil { + return nil, fmt.Errorf("сбор информации о нодах не удался: %v", err) + } + + log.Printf("Сканирование завершено за %v, найдено %d нод", time.Since(start), len(nodes)) + return nodes, nil +} + +// ScanNetworkWithProgress сканирует сеть с отображением прогресса +func (s *NetworkScannerImpl) ScanNetworkWithProgress(ctx context.Context, cidr string, progressFunc func(int)) ([]NodeInfo, error) { + log.Printf("[FIXED] Запуск сканирования сети с прогрессом для CIDR: %s", cidr) + + // Функция обновления прогресса с heartbeat + updateProgress := func(stage string, percent int) { + log.Printf("[FIXED] Прогресс %s: %d%%", stage, percent) + if progressFunc != nil { + log.Printf("[FIXED] Вызов progressFunc с %d%%", percent) + progressFunc(percent) + } else { + log.Printf("[FIXED] progressFunc == nil!") + } + } + + updateProgress("Начало", 5) + + // Scan the network с heartbeat + heartbeatCtx, heartbeatCancel := context.WithCancel(ctx) + defer heartbeatCancel() + + // Запускаем heartbeat для обновления прогресса + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-heartbeatCtx.Done(): + return + case <-ticker.C: + log.Printf("[FIXED] Heartbeat: сканирование в процессе...") + if progressFunc != nil { + // Обновляем прогресс только если он не слишком высокий + // (избегаем превышения 90% до завершения) + // progressFunc(15) // Обновляем только для heartbeat + } + } + } + }() + + updateProgress("Сканирование nmap", 10) + + // Scan the network + ips, err := s.scanForTalOSNodes(ctx, cidr) + if err != nil { + heartbeatCancel() + return nil, fmt.Errorf("сканирование сети не удалось: %v", err) + } + + heartbeatCancel() // Останавливаем heartbeat + updateProgress("Nmap завершен", 20) + + if len(ips) == 0 { + return nil, fmt.Errorf("в сети не найдено нод с открытым портом 50000") + } + + log.Printf("[FIXED] Найдено %d IP адресов, начинаем параллельное сканирование", len(ips)) + + // Параллельно собираем информацию о нодах + nodes, err := s.parallelScanWithProgress(ctx, ips, progressFunc) + if err != nil { + return nil, fmt.Errorf("сбор информации о нодах не удался: %v", err) + } + + updateProgress("Завершено", 100) + log.Printf("[FIXED] Сканирование с прогрессом завершено, найдено %d нод", len(nodes)) + return nodes, nil +} + +// IsTalosNode проверяет, является ли IP адрес нодой Talos +func (s *NetworkScannerImpl) IsTalosNode(ctx context.Context, ip string) bool { + log.Printf("[FIXED] Проверка, является ли %s нодой Talos", ip) + log.Printf("[FIXED] Контекст в IsTalosNode: %v", ctx.Err()) + + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + log.Printf("[FIXED] IsTalosNode отменен в начале для IP %s", ip) + return false + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-n", ip, "get", "machinestatus", "--insecure") + log.Printf("[FIXED] Создана команда talosctl для IP %s: %v", ip, cmd.Args) + + log.Printf("[FIXED] Выполняем CombinedOutput() для IP %s...", ip) + output, err := cmd.CombinedOutput() + + // Проверяем причину завершения команды + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Команда talosctl для IP %s превысила таймаут 5 сек", ip) + return false + } + + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда talosctl для IP %s была отменена", ip) + return false + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст был отменен для IP %s: %v", ip, ctx.Err()) + return false + } + + if err != nil { + log.Printf("[FIXED] Команда talosctl не удалась для IP %s: %v, output: %s", ip, err, string(output)) + return false + } + + log.Printf("[FIXED] IP %s является нодой Talos", ip) + return true +} + +// CollectNodeInfoEnhanced собирает подробную информацию о ноде с использованием NodeManager +func (s *NetworkScannerImpl) CollectNodeInfoEnhanced(ctx context.Context, ip string) (NodeInfo, error) { + log.Printf("[ENHANCED] Начинаем расширенный сбор информации о ноде для IP: %s", ip) + + node := NodeInfo{ + Name: ip, + IP: ip, + } + + // Получаем имя хоста через NodeManager + if hostname, err := s.getHostnameViaNodeManager(ctx, ip); err == nil { + node.Hostname = hostname + } else { + node.Hostname = ip + log.Printf("[ENHANCED] Не удалось получить имя хоста через NodeManager для %s: %v", ip, err) + } + + // Получаем расширенную информацию об оборудовании + hardware, err := s.getEnhancedHardwareInfo(ctx, ip) + if err != nil { + log.Printf("[ENHANCED] Ошибка получения расширенной информации об оборудовании для %s: %v", ip, err) + // Возвращаем базовую информацию, даже если не удалось получить детали + return node, nil + } + node.Hardware = hardware + + // Вычисляем ресурсы на основе полученной информации + node.CPU = 0 + for _, p := range hardware.Processors { + node.CPU += p.ThreadCount + } + + node.RAM = hardware.Memory.Size / 1024 // Конвертируем MiB в GiB + node.Disks = hardware.Blockdevices + + // [COMPATIBILITY] Логи проверки совместимости структур + log.Printf("[COMPAT-ENHANCED] Заполнение NodeInfo для %s:", ip) + log.Printf("[COMPAT-ENHANCED] - CPU: %d", node.CPU) + log.Printf("[COMPAT-ENHANCED] - RAM: %d GiB", node.RAM) + log.Printf("[COMPAT-ENHANCED] - Disks: %d устройств", len(node.Disks)) + for i, disk := range node.Disks { + log.Printf("[COMPAT-ENHANCED] - Disk[%d]: Name=%s, DevPath=%s, Size=%d", + i, disk.Name, disk.DevPath, disk.Size) + } + + // Устанавливаем производителя и MAC адрес + if len(hardware.Processors) > 0 { + node.Manufacturer = hardware.Processors[0].ProductName + if hardware.Processors[0].Manufacturer != "" { + node.Manufacturer = hardware.Processors[0].Manufacturer + } + } + + if len(hardware.Interfaces) > 0 { + node.MAC = hardware.Interfaces[0].MAC + } + + log.Printf("[ENHANCED] Расширенная информация о ноде %s успешно собрана", ip) + return node, nil +} + +// getHostnameViaNodeManager получает имя хоста через commandExecutor +func (s *NetworkScannerImpl) getHostnameViaNodeManager(ctx context.Context, ip string) (string, error) { + if s.commandExecutor == nil { + return "", fmt.Errorf("commandExecutor не инициализирован") + } + + // Получаем информацию о версии, которая также содержит hostname + output, err := s.commandExecutor.ExecuteNodeCommand(ctx, ip, "version") + if err != nil { + return "", err + } + + // Парсим hostname из вывода версии + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.Contains(line, "Hostname:") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + return strings.TrimSpace(parts[1]), nil + } + } + } + + return ip, nil +} + +// getEnhancedHardwareInfo получает расширенную информацию об оборудовании через commandExecutor +func (s *NetworkScannerImpl) getEnhancedHardwareInfo(ctx context.Context, ip string) (Hardware, error) { + log.Printf("[ENHANCED] Получение расширенной информации об оборудовании для %s", ip) + var hardware Hardware + + if s.commandExecutor == nil { + return hardware, fmt.Errorf("commandExecutor не инициализирован") + } + + // Получаем детальную информацию о памяти + if memoryInfo, err := s.getMemoryViaNodeManager(ctx, ip); err == nil { + hardware.Memory = memoryInfo + log.Printf("[ENHANCED] Получена детальная информация о памяти для %s: %d MiB", ip, memoryInfo.Size) + } else { + log.Printf("[ENHANCED] Не удалось получить информацию о памяти через commandExecutor для %s: %v", ip, err) + } + + // Получаем информацию о дисках (используем прямой метод через talosctl) + if disksInfo, err := s.getBlockdevices(ctx, ip); err == nil { + hardware.Blockdevices = disksInfo + log.Printf("[ENHANCED] Получена информация о %d дисках для %s через getBlockdevices", len(disksInfo), ip) + } else { + log.Printf("[ENHANCED] Не удалось получить информацию о дисках через getBlockdevices для %s: %v", ip, err) + // Попробуем fallback через commandExecutor + if disksInfo, err := s.getDisksViaNodeManager(ctx, ip); err == nil { + hardware.Blockdevices = disksInfo + log.Printf("[ENHANCED] Получена информация о %d дисках через fallback для %s", len(disksInfo), ip) + } else { + log.Printf("[ENHANCED] Не удалось получить информацию о дисках через fallback для %s: %v", ip, err) + } + } + + // Получаем информацию о процессах (для определения CPU) + if processesInfo, err := s.getProcessesInfoViaNodeManager(ctx, ip); err == nil { + // Извлекаем информацию о CPU из процессов + if len(processesInfo) > 0 { + // Создаем фиктивный процессор на основе количества процессов + processor := Processor{ + Manufacturer: "Unknown", + ProductName: "CPU (из процессов)", + ThreadCount: len(processesInfo), + } + hardware.Processors = []Processor{processor} + log.Printf("[ENHANCED] Получена информация о CPU для %s на основе процессов", ip) + } + } else { + log.Printf("[ENHANCED] Не удалось получить информацию о процессах через commandExecutor для %s: %v", ip, err) + } + + // Получаем информацию о сетевых интерфейсах + if interfacesInfo, err := s.getInterfacesViaNodeManager(ctx, ip); err == nil { + hardware.Interfaces = interfacesInfo + log.Printf("[ENHANCED] Получена информация о %d сетевых интерфейсах для %s", len(interfacesInfo), ip) + } else { + log.Printf("[ENHANCED] Не удалось получить информацию о сетевых интерфейсах через commandExecutor для %s: %v", ip, err) + } + + log.Printf("[ENHANCED] Расширенная информация об оборудовании для %s успешно получена", ip) + return hardware, nil +} + +// getMemoryViaNodeManager получает информацию о памяти через NodeManager +func (s *NetworkScannerImpl) getMemoryViaNodeManager(ctx context.Context, ip string) (Memory, error) { + output, err := s.commandExecutor.ExecuteNodeCommand(ctx, ip, "memory") + if err != nil { + return Memory{}, err + } + + // Парсим вывод команды memory + lines := strings.Split(output, "\n") + var result Memory + for _, line := range lines { + if strings.Contains(line, "Общая память:") { + parts := strings.Split(line, ":") + if len(parts) >= 2 { + sizeStr := strings.TrimSpace(parts[1]) + // Конвертируем в байты (упрощенно) + if strings.Contains(sizeStr, "GiB") { + // Извлекаем числовое значение + var sizeGB int + if value, err := fmt.Sscanf(sizeStr, "%d GiB", &sizeGB); err == nil && value == 1 { + result.Size = sizeGB * 1024 // Конвертируем GiB в MiB + return result, nil + } + } + if strings.Contains(sizeStr, "MiB") { + if value, err := fmt.Sscanf(sizeStr, "%d MiB", &result.Size); err == nil && value == 1 { + return result, nil + } + } + } + } + } + + return Memory{}, fmt.Errorf("не удалось распарсить информацию о памяти") +} + +// getDisksViaNodeManager получает информацию о дисках через NodeManager +func (s *NetworkScannerImpl) getDisksViaNodeManager(ctx context.Context, ip string) ([]Blockdevice, error) { + log.Printf("[FALLBACK] Получение информации о дисках через NodeManager для %s", ip) + + output, err := s.commandExecutor.ExecuteNodeCommand(ctx, ip, "disks") + if err != nil { + log.Printf("[FALLBACK] Ошибка выполнения команды disks для %s: %v", ip, err) + return nil, err + } + + log.Printf("[FALLBACK] Получен вывод команды disks для %s: %s", ip, output) + + var disks []Blockdevice + lines := strings.Split(output, "\n") + + for lineNum, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + log.Printf("[FALLBACK] Обработка строки %d: %s", lineNum, line) + + // Парсим строки вида: "sda\t\t32 GB\tSATA SSD" + parts := strings.Split(line, "\t") + if len(parts) >= 2 { + deviceName := strings.TrimSpace(parts[0]) + sizeStr := strings.TrimSpace(parts[1]) + + log.Printf("[FALLBACK] Парсинг диска: device=%s, sizeStr=%s", deviceName, sizeStr) + + // Конвертируем размер в байты + var sizeBytes int + if strings.Contains(sizeStr, "GB") { + var sizeGB int + if _, err := fmt.Sscanf(sizeStr, "%d GB", &sizeGB); err == nil { + sizeBytes = sizeGB * 1024 * 1024 * 1024 + log.Printf("[FALLBACK] Конвертирован размер: %d GB -> %d байт", sizeGB, sizeBytes) + } + } else if strings.Contains(sizeStr, "TB") { + var sizeTB int + if _, err := fmt.Sscanf(sizeStr, "%d TB", &sizeTB); err == nil { + sizeBytes = sizeTB * 1024 * 1024 * 1024 * 1024 + log.Printf("[FALLBACK] Конвертирован размер: %d TB -> %d байт", sizeTB, sizeBytes) + } + } else { + log.Printf("[FALLBACK] Неизвестный формат размера: %s", sizeStr) + } + + // Фильтруем слишком маленькие диски (меньше 3GB) и нежелательные устройства + minSize := 3 * 1024 * 1024 * 1024 // 3GB в байтах + isUnwantedDevice := strings.HasPrefix(deviceName, "zd") || + strings.HasPrefix(deviceName, "drbd") || + strings.HasPrefix(deviceName, "loop") || + strings.HasPrefix(deviceName, "sr") + + if sizeBytes > 0 && sizeBytes >= minSize && !isUnwantedDevice { + disk := Blockdevice{ + Name: deviceName, + Size: sizeBytes, + DevPath: "/dev/" + deviceName, + Metadata: struct { + ID string `json:"id"` + }{ID: deviceName}, + } + + // Добавляем тип диска если есть + if len(parts) >= 3 { + disk.Transport = strings.TrimSpace(parts[2]) + } + + // Пытаемся определить модель диска если есть дополнительная информация + if len(parts) >= 4 { + disk.Model = strings.TrimSpace(parts[3]) + } + + // [COMPATIBILITY] Диагностические логи для fallback метода + log.Printf("[COMPAT-FALLBACK] Blockdevice создано через fallback для %s:", ip) + log.Printf("[COMPAT-FALLBACK] - Name: %s (JSON tag: %s)", disk.Name, "исключено из JSON") + log.Printf("[COMPAT-FALLBACK] - DevPath: %s (JSON tag: %s)", disk.DevPath, "dev_path") + log.Printf("[COMPAT-FALLBACK] - Size: %d (JSON tag: %s)", disk.Size, "size") + log.Printf("[COMPAT-FALLBACK] - Model: %s (JSON tag: %s)", disk.Model, "model") + log.Printf("[COMPAT-FALLBACK] - Transport: %s (JSON tag: %s)", disk.Transport, "transport") + log.Printf("[COMPAT-FALLBACK] - Metadata.ID: %s (JSON tag: %s)", disk.Metadata.ID, "id") + + log.Printf("[FALLBACK] Создан диск: Name=%s, Size=%d, DevPath=%s, Transport=%s, Model=%s", + disk.Name, disk.Size, disk.DevPath, disk.Transport, disk.Model) + + disks = append(disks, disk) + } else { + reasons := []string{} + if sizeBytes == 0 { + reasons = append(reasons, "нулевой размер") + } + if sizeBytes > 0 && sizeBytes < minSize { + reasons = append(reasons, fmt.Sprintf("размер %d байт меньше минимального %d байт", sizeBytes, minSize)) + } + if isUnwantedDevice { + reasons = append(reasons, "нежелательное устройство") + } + + log.Printf("[FALLBACK] Диск %s пропущен: %v", deviceName, strings.Join(reasons, ", ")) + } + } else { + log.Printf("[FALLBACK] Строка %d не содержит достаточно частей: %v", lineNum, parts) + } + } + + log.Printf("[FALLBACK] Итого найдено %d дисков через NodeManager для %s", len(disks), ip) + return disks, nil +} + +// getProcessesInfoViaNodeManager получает информацию о процессах через NodeManager +func (s *NetworkScannerImpl) getProcessesInfoViaNodeManager(ctx context.Context, ip string) ([]map[string]interface{}, error) { + output, err := s.commandExecutor.ExecuteNodeCommand(ctx, ip, "processes") + if err != nil { + return nil, err + } + + var processes []map[string]interface{} + lines := strings.Split(output, "\n") + + for _, line := range lines { + if strings.TrimSpace(line) == "" || strings.Contains(line, "PID") || strings.Contains(line, "---") { + continue + } + + // Простой парсинг строки процесса + parts := strings.Fields(line) + if len(parts) >= 4 { + process := map[string]interface{}{ + "pid": parts[0], + "name": parts[1], + "cpu": parts[2], + "memory": parts[3], + } + processes = append(processes, process) + } + } + + return processes, nil +} + +// getInterfacesViaNodeManager получает информацию о сетевых интерфейсах через NodeManager +func (s *NetworkScannerImpl) getInterfacesViaNodeManager(ctx context.Context, ip string) ([]Interface, error) { + // NodeManager не имеет прямой команды для интерфейсов, + // используем стандартный метод + return s.getInterfaces(ctx, ip) +} + +// CollectNodeInfo собирает подробную информацию о ноде с использованием NodeManager +func (s *NetworkScannerImpl) CollectNodeInfo(ctx context.Context, ip string) (NodeInfo, error) { + return s.CollectNodeInfoEnhanced(ctx, ip) +} + +// ParallelScan параллельно сканирует список IP адресов +func (s *NetworkScannerImpl) ParallelScan(ctx context.Context, ips []string) ([]NodeInfo, error) { + return s.parallelScanWithProgress(ctx, ips, nil) +} + +// parallelScanWithProgress внутренняя функция для параллельного сканирования с прогрессом +func (s *NetworkScannerImpl) parallelScanWithProgress(ctx context.Context, ips []string, progressFunc func(int)) ([]NodeInfo, error) { + total := len(ips) + if total == 0 { + log.Printf("[FIXED] Нет IP адресов для сканирования") + return nil, nil + } + + log.Printf("[FIXED] Запуск параллельного сканирования %d IP адресов", total) + log.Printf("[FIXED] progressFunc == nil: %v", progressFunc == nil) + + // Настройки worker pool + numWorkers := 10 + if total < numWorkers { + numWorkers = total + } + + log.Printf("[DIAGNOSTIC] Настройка пула воркеров: %d воркеров для %d IP адресов", numWorkers, total) + + type job struct { + index int + ip string + } + + type result struct { + index int + node NodeInfo + err error + found bool + } + + jobChan := make(chan job, total) + resultChan := make(chan result, total) + + // Флаг для early termination + foundNodes := make([]NodeInfo, 0, total) + var foundMutex sync.Mutex + targetNodes := 3 // Максимум нод для поиска (early termination) + + // Запускаем воркеры + var wg sync.WaitGroup + ctx, cancel := context.WithCancel(ctx) + defer cancel() + for w := 0; w < numWorkers; w++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + log.Printf("[FIXED] Воркер %d запущен", workerID) + + for j := range jobChan { + // Проверяем early termination + foundMutex.Lock() + shouldStop := len(foundNodes) >= targetNodes + foundMutex.Unlock() + + if shouldStop { + log.Printf("[FIXED] Воркер %d остановлен: достигнуто целевое количество нод (%d)", workerID, targetNodes) + return + } + + select { + case <-ctx.Done(): + log.Printf("[FIXED] Воркер %d отменен из-за контекста", workerID) + return + default: + } + + var res result + res.index = j.index + + // Проверяем, является ли IP нодой Talos + if s.IsTalosNode(ctx, j.ip) { + log.Printf("[FIXED] Воркер %d: IP %s является нодой Talos, собираем информацию", workerID, j.ip) + node, err := s.CollectNodeInfo(ctx, j.ip) + if err == nil { + res.node = node + res.found = true + + // Добавляем в найденные ноды для early termination + foundMutex.Lock() + foundNodes = append(foundNodes, node) + log.Printf("[FIXED] Воркер %d: найдена нода %s, всего найдено: %d", workerID, node.Hostname, len(foundNodes)) + foundMutex.Unlock() + } else { + res.err = err + log.Printf("[FIXED] Воркер %d: ошибка сбора информации о %s: %v", workerID, j.ip, err) + } + } else { + log.Printf("[FIXED] Воркер %d: IP %s не является нодой Talos", workerID, j.ip) + } + + log.Printf("[FIXED] Воркер %d отправляет результат для IP %s (найдено: %v)", workerID, j.ip, res.found) + resultChan <- res + } + log.Printf("[FIXED] Воркер %d завершен", workerID) + }(w) + } + + // Отправляем задачи + go func() { + log.Printf("Начинаем отправку задач в jobChan") + for i, ip := range ips { + select { + case <-ctx.Done(): + log.Printf("Контекст отменен во время отправки задач") + return + default: + log.Printf("Отправляем задачу для IP %s (индекс %d)", ip, i) + jobChan <- job{index: i, ip: ip} + } + } + log.Printf("Закрываем jobChan") + close(jobChan) + }() + + // Ждем завершения воркеров + go func() { + log.Printf("Ждем завершения воркеров") + wg.Wait() + log.Printf("Все воркеры завершены, закрываем resultChan") + close(resultChan) + }() + + // Собираем результаты + results := make([]result, total) + completed := 0 + foundCount := 0 + log.Printf("[FIXED] Начинаем сбор результатов из resultChan") + + for res := range resultChan { + log.Printf("Получен результат для индекса %d, найдено: %v", res.index, res.found) + results[res.index] = res + completed++ + + if res.found { + foundCount++ + } + + // Обновляем прогресс + if progressFunc != nil { + newProgress := 20 + completed*80/total // 20% уже пройдено, осталось 80% + if newProgress > 100 { + newProgress = 100 + } + log.Printf("[FIXED] Вызов progressFunc с %d%% (completed: %d, total: %d)", newProgress, completed, total) + progressFunc(newProgress) + log.Printf("[FIXED] Прогресс обновлен: %d%% (%d/%d задач, %d нод найдено)", newProgress, completed, total, foundCount) + } else { + log.Printf("[FIXED] progressFunc == nil в parallelScanWithProgress!") + } + } + + log.Printf("Сбор результатов завершен, всего завершено: %d", completed) + + // Фильтруем найденные ноды + var filteredNodes []NodeInfo + for _, res := range results { + if res.found && res.err == nil && res.node.IP != "" { + filteredNodes = append(filteredNodes, res.node) + } + } + + return filteredNodes, nil +} + +// scanForTalOSNodes сканирует сеть на наличие нод с открытым портом 50000 +func (s *NetworkScannerImpl) scanForTalOSNodes(ctx context.Context, cidr string) ([]string, error) { + log.Printf("[FIXED] Сканирование сети %s на наличие нод с открытым портом 50000", cidr) + log.Printf("[FIXED] Контекст сканирования: %v", ctx.Err()) + + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + log.Printf("[FIXED] Сканирование отменено в начале scanForTalOSNodes") + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + default: + } + + var output []byte + var err error + + // Первая попытка с таймаутом + { + log.Printf("[FIXED] Выполняем первую команду nmap...") + cmd := exec.Command("nmap", "-p", "50000", "--open", "-oG", "-", cidr) + + // Проверяем отмену перед выполнением команды + select { + case <-ctx.Done(): + log.Printf("[FIXED] Сканирование отменено перед первой командой nmap") + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + default: + } + + cmd = exec.CommandContext(ctx, cmd.Args[0], cmd.Args[1:]...) + + output, err = cmd.Output() + + // Проверяем отмену после выполнения команды + if ctx.Err() != nil { + if ctx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Первая команда nmap превысила таймаут или была отменена") + err = fmt.Errorf("nmap timeout or cancelled after 15 seconds") + } else { + log.Printf("[FIXED] Первая команда nmap была отменена: %v", ctx.Err()) + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + } + } + } + + if err != nil { + log.Printf("[FIXED] Первая команда nmap не удалась: %v, пробуем альтернативу", err) + + // Проверяем отмену перед второй попыткой + select { + case <-ctx.Done(): + log.Printf("[FIXED] Сканирование отменено перед второй командой nmap") + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + default: + } + + // Вторая попытка с альтернативными параметрами + { + log.Printf("[FIXED] Выполняем альтернативную команду nmap...") + cmd := exec.Command("nmap", "-p", "50000", "-sT", "--open", "-oG", "-", cidr) + + cmd = exec.CommandContext(ctx, cmd.Args[0], cmd.Args[1:]...) + + output, err = cmd.Output() + + // Проверяем отмену после второй команды + if ctx.Err() != nil { + if ctx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Альтернативная команда nmap превысила таймаут или была отменена") + err = fmt.Errorf("alternative nmap timeout or cancelled after 10 seconds") + } else { + log.Printf("[FIXED] Альтернативная команда nmap была отменена: %v", ctx.Err()) + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + } + } + } + + if err != nil { + log.Printf("[FIXED] Альтернативная команда nmap не удалась: %v", err) + return nil, fmt.Errorf("nmap scan failed: %v", err) + } + } + + // Проверяем отмену перед обработкой результатов + select { + case <-ctx.Done(): + log.Printf("[FIXED] Сканирование отменено при обработке результатов") + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + default: + } + + log.Printf("[FIXED] Команда nmap выполнена успешно, длина вывода: %d", len(output)) + + outputStr := string(output) + var ips []string + lines := strings.Split(outputStr, "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Проверяем отмену в цикле обработки строк + select { + case <-ctx.Done(): + log.Printf("[FIXED] Сканирование отменено при обработке строк результатов") + return nil, fmt.Errorf("сканирование отменено: %v", ctx.Err()) + default: + } + + // Проверяем, содержит ли строка информацию об открытом порте 50000 + if strings.Contains(line, "50000/open") { + // Ищем IP в строке + re := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) + matches := re.FindAllString(line, -1) + + for _, match := range matches { + if parsedIP := net.ParseIP(match); parsedIP != nil { + // Проверяем, не дубликат ли это + duplicate := false + for _, existing := range ips { + if existing == match { + duplicate = true + break + } + } + + if !duplicate { + ips = append(ips, match) + } + } + } + } + } + + log.Printf("Найдено IP адресов с открытым портом 50000: %v", ips) + return ips, nil +} + +// getHostname получает имя хоста для IP адреса +func (s *NetworkScannerImpl) getHostname(ctx context.Context, ip string) (string, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return "", fmt.Errorf("getHostname отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "hostname", "-i", "-o", "jsonpath={.spec.hostname}") + log.Printf("[FIXED] Получение hostname для %s: %v", ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Timeout при получении hostname для %s", ip) + return "", fmt.Errorf("hostname timeout for %s", ip) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда hostname отменена для %s", ip) + return "", fmt.Errorf("hostname cancelled for %s", ip) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст отменен при получении hostname для %s: %v", ip, ctx.Err()) + return "", fmt.Errorf("getHostname отменен для %s: %v", ip, ctx.Err()) + } + + if err != nil { + log.Printf("[FIXED] Ошибка при получении hostname для %s: %v", ip, err) + return "", err + } + + result := strings.TrimSpace(string(output)) + log.Printf("[FIXED] Hostname для %s: %s", ip, result) + return result, nil +} + +// getHardwareInfo получает информацию об оборудовании ноды +func (s *NetworkScannerImpl) getHardwareInfo(ctx context.Context, ip string) (Hardware, error) { + log.Printf("Получение информации об оборудовании для %s", ip) + var hardware Hardware + + // Получаем процессоры + log.Printf("Получение процессоров для %s", ip) + processors, err := s.getProcessors(ctx, ip) + if err != nil { + return hardware, fmt.Errorf("не удалось получить процессоры: %v", err) + } + log.Printf("Получено %d процессоров для %s", len(processors), ip) + hardware.Processors = processors + + // Получаем память + log.Printf("Получение памяти для %s", ip) + memory, err := s.getMemory(ctx, ip) + if err != nil { + return hardware, fmt.Errorf("не удалось получить память: %v", err) + } + log.Printf("Получена память для %s: %d MiB", ip, memory.Size) + hardware.Memory = memory + + // Получаем блочные устройства + log.Printf("Получение блочных устройств для %s", ip) + blockdevices, err := s.getBlockdevices(ctx, ip) + if err != nil { + log.Printf("Не удалось получить блочные устройства для %s: %v, продолжаем без них", ip, err) + blockdevices = []Blockdevice{} + } + log.Printf("Получено %d блочных устройств для %s", len(blockdevices), ip) + hardware.Blockdevices = blockdevices + + // Получаем сетевые интерфейсы + log.Printf("Получение сетевых интерфейсов для %s", ip) + interfaces, err := s.getInterfaces(ctx, ip) + if err != nil { + log.Printf("Не удалось получить сетевые интерфейсы для %s: %v, продолжаем без них", ip, err) + interfaces = []Interface{} + } + log.Printf("Получено %d сетевых интерфейсов для %s", len(interfaces), ip) + hardware.Interfaces = interfaces + + log.Printf("Информация об оборудовании для %s успешно получена", ip) + return hardware, nil +} + +// getProcessors получает информацию о процессорах +func (s *NetworkScannerImpl) getProcessors(ctx context.Context, ip string) ([]Processor, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return nil, fmt.Errorf("getProcessors отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "cpu", "-i", "-o", "json") + log.Printf("[FIXED] Получение CPU для %s: %v", ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Timeout при получении CPU для %s", ip) + return nil, fmt.Errorf("cpu timeout for %s", ip) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда CPU отменена для %s", ip) + return nil, fmt.Errorf("cpu cancelled for %s", ip) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст отменен при получении CPU для %s: %v", ip, ctx.Err()) + return nil, fmt.Errorf("getProcessors отменен для %s: %v", ip, ctx.Err()) + } + + if err != nil { + log.Printf("[FIXED] Ошибка при получении CPU для %s: %v", ip, err) + return nil, err + } + + var resp struct { + Spec Processor `json:"spec"` + } + if err := json.Unmarshal(output, &resp); err != nil { + log.Printf("[FIXED] Ошибка декодирования CPU для %s: %v", ip, err) + return nil, err + } + + log.Printf("[FIXED] Получен CPU для %s: %v", ip, resp.Spec) + return []Processor{resp.Spec}, nil +} + +// getMemory получает информацию о памяти +func (s *NetworkScannerImpl) getMemory(ctx context.Context, ip string) (Memory, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return Memory{}, fmt.Errorf("getMemory отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "ram", "-i", "-o", "json") + log.Printf("[FIXED] Получение RAM для %s: %v", ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Timeout при получении RAM для %s", ip) + return Memory{}, fmt.Errorf("ram timeout for %s", ip) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда RAM отменена для %s", ip) + return Memory{}, fmt.Errorf("ram cancelled for %s", ip) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст отменен при получении RAM для %s: %v", ip, ctx.Err()) + return Memory{}, fmt.Errorf("getMemory отменен для %s: %v", ip, ctx.Err()) + } + + if err != nil { + log.Printf("[FIXED] Ошибка при получении RAM для %s: %v", ip, err) + return Memory{}, err + } + + var resp struct { + Spec Memory `json:"spec"` + } + if err := json.Unmarshal(output, &resp); err != nil { + log.Printf("[FIXED] Ошибка декодирования RAM для %s: %v", ip, err) + return Memory{}, err + } + + log.Printf("[FIXED] Получена RAM для %s: %d MiB", ip, resp.Spec.Size) + return resp.Spec, nil +} + +// getBlockdevices получает информацию о блочных устройствах +func (s *NetworkScannerImpl) getBlockdevices(ctx context.Context, ip string) ([]Blockdevice, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return nil, fmt.Errorf("getBlockdevices отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "disks", "-i", "-o", "json") + log.Printf("[FIXED] Получение дисков для %s: %v", ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Timeout при получении дисков для %s", ip) + return nil, fmt.Errorf("disks timeout for %s", ip) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда дисков отменена для %s", ip) + return nil, fmt.Errorf("disks cancelled for %s", ip) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст отменен при получении дисков для %s: %v", ip, ctx.Err()) + return nil, fmt.Errorf("getBlockdevices отменен для %s: %v", ip, ctx.Err()) + } + + if err != nil { + log.Printf("[FIXED] Ошибка при получении дисков для %s: %v", ip, err) + return nil, err + } + + decoder := json.NewDecoder(strings.NewReader(string(output))) + var blockdevices []Blockdevice + for { + // Проверяем отмену в цикле декодирования + select { + case <-ctx.Done(): + log.Printf("[FIXED] Отменен при декодировании дисков для %s", ip) + return nil, fmt.Errorf("getBlockdevices отменен при декодировании для %s: %v", ip, ctx.Err()) + default: + } + + var resp struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + Spec struct { + Size int `json:"size"` + DevPath string `json:"dev_path"` + Model string `json:"model"` + Transport string `json:"transport"` + } `json:"spec"` + } + if err := decoder.Decode(&resp); err != nil { + if err == io.EOF { + break + } + log.Printf("[FIXED] Не удалось декодировать JSON объект для %s: %v", ip, err) + return nil, err + } + bd := Blockdevice{ + Name: resp.Metadata.ID, + Size: resp.Spec.Size, + DevPath: resp.Spec.DevPath, + Model: resp.Spec.Model, + Transport: resp.Spec.Transport, + Metadata: resp.Metadata, + } + + // [COMPATIBILITY] Диагностические логи для проверки совместимости структур + log.Printf("[COMPAT] Blockdevice создано для %s:", ip) + log.Printf("[COMPAT] - Name: %s (JSON tag: %s)", bd.Name, "исключено из JSON") + log.Printf("[COMPAT] - DevPath: %s (JSON tag: %s)", bd.DevPath, "dev_path") + log.Printf("[COMPAT] - Size: %d (JSON tag: %s)", bd.Size, "size") + log.Printf("[COMPAT] - Model: %s (JSON tag: %s)", bd.Model, "model") + log.Printf("[COMPAT] - Transport: %s (JSON tag: %s)", bd.Transport, "transport") + log.Printf("[COMPAT] - Metadata.ID: %s (JSON tag: %s)", bd.Metadata.ID, "id") + blockdevices = append(blockdevices, bd) + } + + // Фильтруем нежелательные устройства и маленькие диски + var filtered []Blockdevice + minSize := 3 * 1024 * 1024 * 1024 // 3 GB + for _, bd := range blockdevices { + if !strings.HasPrefix(bd.Name, "zd") && !strings.HasPrefix(bd.Name, "drbd") && + !strings.HasPrefix(bd.Name, "loop") && !strings.HasPrefix(bd.Name, "sr") && bd.Size >= minSize { + filtered = append(filtered, bd) + } + } + + log.Printf("[FIXED] Получено %d дисков для %s", len(filtered), ip) + return filtered, nil +} + +// getInterfaceIPs получает IP адреса интерфейса через talosctl +func (s *NetworkScannerImpl) getInterfaceIPs(ctx context.Context, ip, interfaceName string) ([]string, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return nil, fmt.Errorf("getInterfaceIPs отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "addresses", "-i", "-o", "json") + log.Printf("[FIXED] Получение IP адресов для интерфейса %s на %s: %v", interfaceName, ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Timeout при получении IP адресов для интерфейса %s на %s", interfaceName, ip) + return nil, fmt.Errorf("interface IPs timeout for %s:%s", ip, interfaceName) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда IP адресов отменена для интерфейса %s на %s", interfaceName, ip) + return nil, fmt.Errorf("interface IPs cancelled for %s:%s", ip, interfaceName) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст отменен при получении IP адресов для %s:%s: %v", ip, interfaceName, ctx.Err()) + return nil, fmt.Errorf("getInterfaceIPs отменен для %s:%s: %v", ip, interfaceName, ctx.Err()) + } + + if err != nil { + log.Printf("[FIXED] Ошибка при получении IP адресов для интерфейса %s на %s: %v", interfaceName, ip, err) + return nil, err + } + + var ips []string + decoder := json.NewDecoder(strings.NewReader(string(output))) + for { + // Проверяем отмену в цикле декодирования + select { + case <-ctx.Done(): + log.Printf("[FIXED] Отменен при декодировании IP адресов для %s:%s", ip, interfaceName) + return nil, fmt.Errorf("getInterfaceIPs отменен при декодировании для %s:%s: %v", ip, interfaceName, ctx.Err()) + default: + } + + var resp struct { + Spec struct { + Address string `json:"address"` + LinkName string `json:"linkName"` + } `json:"spec"` + } + if err := decoder.Decode(&resp); err != nil { + if err == io.EOF { + break + } + log.Printf("[FIXED] Ошибка декодирования IP адресов для %s:%s: %v", ip, interfaceName, err) + return nil, err + } + + // Фильтруем только адреса для нужного интерфейса + if resp.Spec.LinkName == interfaceName { + ips = append(ips, resp.Spec.Address) + } + } + + log.Printf("[FIXED] Получено %d IP адресов для интерфейса %s на %s: %v", len(ips), interfaceName, ip, ips) + return ips, nil +} + +// getInterfaceStatus получает статус интерфейса через talosctl +func (s *NetworkScannerImpl) getInterfaceStatus(ctx context.Context, ip, interfaceName string) (string, bool, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return "", false, fmt.Errorf("getInterfaceStatus отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "link", "-i", "-o", "json") + log.Printf("[FIXED] Получение статуса интерфейса %s на %s: %v", interfaceName, ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[FIXED] Timeout при получении статуса интерфейса %s на %s", interfaceName, ip) + return "", false, fmt.Errorf("interface status timeout for %s:%s", ip, interfaceName) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[FIXED] Команда статуса отменена для интерфейса %s на %s", interfaceName, ip) + return "", false, fmt.Errorf("interface status cancelled for %s:%s", ip, interfaceName) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[FIXED] Внешний контекст отменен при получении статуса для %s:%s: %v", ip, interfaceName, ctx.Err()) + return "", false, fmt.Errorf("getInterfaceStatus отменен для %s:%s: %v", ip, interfaceName, ctx.Err()) + } + + if err != nil { + log.Printf("[FIXED] Ошибка при получении статуса интерфейса %s на %s: %v", interfaceName, ip, err) + return "", false, err + } + + decoder := json.NewDecoder(strings.NewReader(string(output))) + for { + // Проверяем отмену в цикле декодирования + select { + case <-ctx.Done(): + log.Printf("[FIXED] Отменен при декодировании статуса для %s:%s", ip, interfaceName) + return "", false, fmt.Errorf("getInterfaceStatus отменен при декодировании для %s:%s: %v", ip, interfaceName, ctx.Err()) + default: + } + + var resp struct { + Spec struct { + Name string `json:"name"` + Logical bool `json:"logical"` + Up bool `json:"up"` + BcastValid bool `json:"bcast"` + MTU int `json:"mtu"` + Kind string `json:"kind"` + HardwareAddr string `json:"hardwareAddr"` + LinkState bool `json:"linkState"` + OperationalState string `json:"operationalState"` + } `json:"spec"` + } + if err := decoder.Decode(&resp); err != nil { + if err == io.EOF { + break + } + log.Printf("[FIXED] Ошибка декодирования статуса для %s:%s: %v", ip, interfaceName, err) + return "", false, err + } + + // Находим нужный интерфейс + if resp.Spec.Name == interfaceName { + status := "down" + if resp.Spec.Up { + status = "up" + } + log.Printf("[FIXED] Получен статус %s для интерфейса %s на %s (up=%v)", status, interfaceName, ip, resp.Spec.Up) + return status, resp.Spec.Up, nil + } + } + + log.Printf("[FIXED] Интерфейс %s не найден при получении статуса на %s", interfaceName, ip) + return "unknown", false, fmt.Errorf("interface %s not found", interfaceName) +} + +// getInterfaces получает информацию о сетевых интерфейсах +func (s *NetworkScannerImpl) getInterfaces(ctx context.Context, ip string) ([]Interface, error) { + // Проверяем отмену контекста в начале + select { + case <-ctx.Done(): + return nil, fmt.Errorf("getInterfaces отменен для %s: %v", ip, ctx.Err()) + default: + } + + // Create context with timeout для этой операции + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + log.Printf("[INTERFACES] Получение сетевых интерфейсов для %s", ip) + + // Получаем информацию о линках и адресах одновременно + // Сначала линки + cmd := exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "link", "-i", "-o", "json") + log.Printf("[INTERFACES] Получение линков для %s: %v", ip, cmd.Args) + + output, err := cmd.Output() + if timeoutCtx.Err() != nil { + if timeoutCtx.Err() == context.DeadlineExceeded { + log.Printf("[INTERFACES] Timeout при получении линков для %s", ip) + return nil, fmt.Errorf("interfaces timeout for %s", ip) + } + if timeoutCtx.Err() == context.Canceled { + log.Printf("[INTERFACES] Команда линков отменена для %s", ip) + return nil, fmt.Errorf("interfaces cancelled for %s", ip) + } + } + + // Проверяем отмену внешнего контекста + if ctx.Err() != nil { + log.Printf("[INTERFACES] Внешний контекст отменен при получении линков для %s: %v", ip, ctx.Err()) + return nil, fmt.Errorf("getInterfaces отменен для %s: %v", ip, ctx.Err()) + } + + if err != nil { + log.Printf("[INTERFACES] Ошибка при получении линков для %s: %v", ip, err) + return nil, err + } + + log.Printf("[INTERFACES] Raw links output: %s", string(output)) + + // Парсим линки + decoder := json.NewDecoder(strings.NewReader(string(output))) + var rawInterfaces []struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + Spec struct { + Name string `json:"name"` + HardwareAddr string `json:"hardwareAddr"` + LinkState bool `json:"linkState"` + Up bool `json:"up"` + Kind string `json:"kind"` + MTU int `json:"mtu"` + } `json:"spec"` + } + + for { + var resp struct { + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` + Spec struct { + Name string `json:"name"` + HardwareAddr string `json:"hardwareAddr"` + LinkState bool `json:"linkState"` + Up bool `json:"up"` + Kind string `json:"kind"` + MTU int `json:"mtu"` + } `json:"spec"` + } + if err := decoder.Decode(&resp); err != nil { + if err == io.EOF { + break + } + log.Printf("[INTERFACES] Ошибка декодирования линков для %s: %v", ip, err) + return nil, err + } + rawInterfaces = append(rawInterfaces, resp) + } + + // Получаем IP адреса + cmd = exec.CommandContext(timeoutCtx, "talosctl", "-e", ip, "-n", ip, "get", "addresses", "-i", "-o", "json") + log.Printf("[INTERFACES] Получение адресов для %s: %v", ip, cmd.Args) + + output, err = cmd.Output() + if err != nil { + log.Printf("[INTERFACES] Ошибка получения адресов для %s: %v", ip, err) + // Продолжаем без IP адресов + } + + // Парсим IP адреса + var allIPs []string + if err == nil { + decoder := json.NewDecoder(strings.NewReader(string(output))) + for { + var resp struct { + Spec struct { + Address string `json:"address"` + LinkName string `json:"linkName"` + } `json:"spec"` + } + if err := decoder.Decode(&resp); err != nil { + if err == io.EOF { + break + } + log.Printf("[INTERFACES] Ошибка декодирования адресов для %s: %v", ip, err) + break + } + allIPs = append(allIPs, fmt.Sprintf("%s %s", resp.Spec.LinkName, resp.Spec.Address)) + } + } + + log.Printf("[INTERFACES] Получено %d линков и %d IP адресов для %s", len(rawInterfaces), len(allIPs), ip) + + // Фильтруем и формируем финальный список интерфейсов согласно shell-скрипту + var interfaces []Interface + for _, rawIface := range rawInterfaces { + // Используем metadata.id как имя интерфейса (как в shell-скрипте) + interfaceName := rawIface.Metadata.ID + if interfaceName == "" { + interfaceName = rawIface.Spec.Name // fallback на spec.name + } + + // Фильтрация согласно shell-скрипту: /^(ID|eno|eth|enp|enx|ens|bond)/ + if interfaceName == "lo" || interfaceName == "docker0" || strings.HasPrefix(interfaceName, "br-") || + strings.HasPrefix(interfaceName, "veth") || strings.HasPrefix(interfaceName, "cali") { + log.Printf("[INTERFACES] Пропускаем нежелательный интерфейс: %s", interfaceName) + continue + } + + // Проверяем соответствие паттерну валидных имен + matched := false + validPrefixes := []string{"eno", "eth", "enp", "enx", "ens", "bond"} + for _, prefix := range validPrefixes { + if strings.HasPrefix(interfaceName, prefix) { + matched = true + break + } + } + + // Если не matched по префиксу, но есть MAC адрес - включаем (для виртуальных интерфейсов) + if !matched && rawIface.Spec.HardwareAddr == "" { + log.Printf("[INTERFACES] Пропускаем интерфейс без MAC и без валидного префикса: %s", interfaceName) + continue + } + + // Находим IP адреса для этого интерфейса + var interfaceIPs []string + for _, ipEntry := range allIPs { + parts := strings.Fields(ipEntry) + if len(parts) >= 2 && parts[0] == interfaceName { + interfaceIPs = append(interfaceIPs, parts[1]) + } + } + + // Создаем структуру интерфейса + iface := Interface{ + Name: interfaceName, + MAC: rawIface.Spec.HardwareAddr, + IPs: interfaceIPs, + } + + interfaces = append(interfaces, iface) + + log.Printf("[INTERFACES] Добавлен интерфейс: %s [MAC: %s] [IPs: %v]", + interfaceName, rawIface.Spec.HardwareAddr, interfaceIPs) + } + + log.Printf("[INTERFACES] Финальный список: %d сетевых интерфейсов для %s", len(interfaces), ip) + return interfaces, nil +} diff --git a/internal/pkg/ui/initwizard/types.go b/internal/pkg/ui/initwizard/types.go new file mode 100644 index 0000000..62bc48d --- /dev/null +++ b/internal/pkg/ui/initwizard/types.go @@ -0,0 +1,130 @@ +package initwizard + +// InitData contains data for cluster initialization +type InitData struct { + TalosVersion string + Preset string + ClusterName string + APIServerURL string + PodSubnets string + ServiceSubnets string + AdvertisedSubnets string + ClusterDomain string + FloatingIP string + Image string + OIDCIssuerURL string + NrHugepages int + NetworkToScan string + SelectedNode string + SelectedNodeInfo NodeInfo + NodeType string + DiscoveredNodes []NodeInfo + Hostname string + Disk string + Interface string + Addresses string + Gateway string + DNSServers string + VIP string + MachineConfig string +} + +// NodeInfo contains node information +type NodeInfo struct { + Name string + IP string + MAC string + Hostname string + Type string + Configured bool + Manufacturer string + CPU int + RAM int + Disks []Blockdevice + Hardware Hardware +} + +// Hostname represents hostname +type Hostname struct { + Hostname string `json:"hostname"` +} + +// Hardware represents hardware information +type Hardware struct { + Processors []Processor `json:"processors"` + Memory Memory `json:"memory"` + Blockdevices []Blockdevice `json:"blockdevices"` + Interfaces []Interface `json:"interfaces"` +} + +// Processor represents processor +type Processor struct { + Manufacturer string `json:"manufacturer"` + ProductName string `json:"productName"` + ThreadCount int `json:"threadCount"` +} + +// Memory represents memory information +type Memory struct { + Size int `json:"size"` +} + +// Blockdevice represents block device +type Blockdevice struct { + Name string `json:"-"` + Size int `json:"size"` + DevPath string `json:"dev_path"` + Model string `json:"model"` + Transport string `json:"transport"` + Metadata struct { + ID string `json:"id"` + } `json:"metadata"` +} + +// Interfaces represents list of network interfaces +type Interfaces struct { + Interfaces []Interface `json:"interfaces"` +} + +// Interface represents network interface +type Interface struct { + Name string `json:"name"` + MAC string `json:"hardwareAddr"` + IPs []string `json:"ips,omitempty"` +} + +// ValuesYAML represents values.yaml structure +type ValuesYAML struct { + ClusterName string `yaml:"clusterName"` + FloatingIP string `yaml:"floatingIP,omitempty"` + KubernetesEndpoint string `yaml:"kubernetesEndpoint"` + EtcdBootstrapped bool `yaml:"etcdBootstrapped"` + Preset string `yaml:"preset"` + TalosVersion string `yaml:"talosVersion"` + APIServerURL string `yaml:"apiServerURL,omitempty"` + PodSubnets string `yaml:"podSubnets,omitempty"` + ServiceSubnets string `yaml:"serviceSubnets,omitempty"` + AdvertisedSubnets string `yaml:"advertisedSubnets,omitempty"` + ClusterDomain string `yaml:"clusterDomain,omitempty"` + Image string `yaml:"image,omitempty"` + OIDCIssuerURL string `yaml:"oidcIssuerURL,omitempty"` + NrHugepages int `yaml:"nrHugepages,omitempty"` + Nodes map[string]NodeConfig `yaml:"nodes,omitempty"` +} + +// NodeConfig represents node configuration +type NodeConfig struct { + Type string `yaml:"type"` + IP string `yaml:"ip"` +} + +// ChartYAML represents Chart.yaml structure +type ChartYAML struct { + APIVersion string `yaml:"apiVersion"` + Name string `yaml:"name"` + Version string `yaml:"version"` + Description string `yaml:"description"` + Type string `yaml:"type"` + AppVersion string `yaml:"appVersion"` + Annotations map[string]string `yaml:"annotations,omitempty"` +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/validator.go b/internal/pkg/ui/initwizard/validator.go new file mode 100644 index 0000000..d66281e --- /dev/null +++ b/internal/pkg/ui/initwizard/validator.go @@ -0,0 +1,259 @@ +package initwizard + +import ( + "fmt" + "net" + "regexp" + "strings" +) + +// ValidatorImpl implements the Validator interface +type ValidatorImpl struct{} + +// NewValidator creates a new validator instance +func NewValidator() Validator { + return &ValidatorImpl{} +} + +// ValidateNetworkCIDR validates the correctness of CIDR network notation +func (v *ValidatorImpl) ValidateNetworkCIDR(cidr string) error { + if strings.TrimSpace(cidr) == "" { + return NewValidationError( + "VAL_001", + "network for scanning cannot be empty", + "CIDR network field is required for scanning", + ) + } + + _, _, err := net.ParseCIDR(cidr) + if err != nil { + return NewValidationErrorWithCause( + "VAL_002", + "incorrect CIDR notation", + fmt.Sprintf("provided CIDR: %s", cidr), + err, + ) + } + + return nil +} + +// ValidateClusterName validates the correctness of the cluster name +func (v *ValidatorImpl) ValidateClusterName(name string) error { + if strings.TrimSpace(name) == "" { + return NewValidationError( + "VAL_003", + "cluster name cannot be empty", + "cluster name field is required", + ) + } + + // Check that the name contains only valid characters + validName := regexp.MustCompile(`^[a-z0-9-]+$`) + if !validName.MatchString(name) { + return NewValidationError( + "VAL_004", + "имя кластера может содержать только строчные буквы, цифры и дефисы", + fmt.Sprintf("предоставленное имя: %s", name), + ) + } + + // Check the name length + if len(name) > 50 { + return NewValidationError( + "VAL_005", + "имя кластера не должно превышать 50 символов", + fmt.Sprintf("текущая длина: %d", len(name)), + ) + } + + return nil +} + +// ValidateHostname validates the correctness of the hostname +func (v *ValidatorImpl) ValidateHostname(hostname string) error { + if strings.TrimSpace(hostname) == "" { + return NewValidationError( + "VAL_006", + "hostname cannot be empty", + "hostname field is required", + ) + } + + // Check that hostname complies with RFC standard + validHostname := regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + if !validHostname.MatchString(hostname) { + return NewValidationError( + "VAL_007", + "incorrect hostname", + fmt.Sprintf("provided hostname: %s", hostname), + ) + } + + return nil +} + +// ValidateRequiredField checks that the required field is not empty +func (v *ValidatorImpl) ValidateRequiredField(value, fieldName string) error { + if strings.TrimSpace(value) == "" { + return NewValidationError( + "VAL_008", + fmt.Sprintf("field '%s' is required", fieldName), + "field value should not be empty", + ) + } + return nil +} + +// ValidateIP validates the correctness of the IP address +func (v *ValidatorImpl) ValidateIP(ip string) error { + if strings.TrimSpace(ip) == "" { + return NewValidationError( + "VAL_009", + "IP address cannot be empty", + "IP address field is required", + ) + } + + if parsedIP := net.ParseIP(ip); parsedIP == nil { + return NewValidationError( + "VAL_010", + "incorrect IP address", + fmt.Sprintf("provided IP: %s", ip), + ) + } + + return nil +} + +// ValidateVIP validates the correctness of the virtual IP address +func (v *ValidatorImpl) ValidateVIP(vip string) error { + if strings.TrimSpace(vip) == "" { + // VIP is optional, empty string is allowed + return nil + } + + return v.ValidateIP(vip) +} + +// ValidateDNSservers validates the correctness of the DNS servers list +func (v *ValidatorImpl) ValidateDNSservers(dns string) error { + if strings.TrimSpace(dns) == "" { + return NewValidationError( + "VAL_011", + "DNS servers cannot be empty", + "at least one DNS server must be specified", + ) + } + + // Split the DNS servers list + dnsServers := strings.Split(dns, ",") + + var invalidServers []string + for _, server := range dnsServers { + server = strings.TrimSpace(server) + if server == "" { + continue + } + + // Check each DNS server + if err := v.ValidateIP(server); err != nil { + invalidServers = append(invalidServers, server) + } + } + + if len(invalidServers) > 0 { + return NewValidationError( + "VAL_012", + "incorrect DNS servers found", + fmt.Sprintf("incorrect servers: %v", invalidServers), + ) + } + + return nil +} + +// ValidateNetworkConfig validates the correctness of the network configuration +func (v *ValidatorImpl) ValidateNetworkConfig(addresses, gateway, dnsServers string) error { + // Check addresses + if err := v.ValidateRequiredField(addresses, "Addresses"); err != nil { + return err + } + + // Check gateway + if err := v.ValidateRequiredField(gateway, "Gateway"); err != nil { + return err + } + + // Check DNS servers + if err := v.ValidateDNSservers(dnsServers); err != nil { + return err + } + + return nil +} + +// ValidateNodeType validates the correctness of the node type +func (v *ValidatorImpl) ValidateNodeType(nodeType string) error { + validTypes := []string{"controlplane", "worker", "control-plane"} + + for _, validType := range validTypes { + if nodeType == validType { + return nil + } + } + + return NewValidationError( + "VAL_013", + "incorrect node type", + fmt.Sprintf("type: %s, valid values: %v", nodeType, validTypes), + ) +} + +// ValidatePreset validates the correctness of the preset +func (v *ValidatorImpl) ValidatePreset(preset string) error { + validPresets := []string{"generic", "cozystack"} + + for _, validPreset := range validPresets { + if preset == validPreset { + return nil + } + } + + return NewValidationError( + "VAL_014", + "incorrect preset", + fmt.Sprintf("preset: %s, valid values: %v", preset, validPresets), + ) +} + +// ValidateAPIServerURL validates the correctness of the API server URL +func (v *ValidatorImpl) ValidateAPIServerURL(url string) error { + if strings.TrimSpace(url) == "" { + return NewValidationError( + "VAL_015", + "API server URL cannot be empty", + "cluster API server URL must be specified", + ) + } + + // Check the basic URL format + if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") { + return NewValidationError( + "VAL_016", + "API server URL must start with http:// or https://", + fmt.Sprintf("provided URL: %s", url), + ) + } + + // Check that the URL contains a port + if !strings.Contains(url, ":") { + return NewValidationError( + "VAL_017", + "API server URL must contain a port (e.g., :6443)", + fmt.Sprintf("provided URL: %s", url), + ) + } + + return nil +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/wizard.go b/internal/pkg/ui/initwizard/wizard.go new file mode 100644 index 0000000..9667018 --- /dev/null +++ b/internal/pkg/ui/initwizard/wizard.go @@ -0,0 +1,405 @@ +package initwizard + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +// WizardImpl реализует интерфейс Wizard +type WizardImpl struct { + data *InitData + app *tview.Application + pages *tview.Pages + validator Validator + scanner NetworkScanner + processor DataProcessor + generator Generator + presenter Presenter +} + +// NewWizard создает новый экземпляр мастера инициализации +func NewWizard() *WizardImpl { + // Initialize data + data := &InitData{ + Preset: "generic", + ClusterName: "mycluster", + NetworkToScan: "192.168.1.0/24", // Значение по умолчанию для сканирования сети + } + + // Create application + app := tview.NewApplication() + pages := tview.NewPages() + + // Create components + validator := NewValidator() + commandExecutor := &DefaultCommandExecutor{} + scanner := NewNetworkScanner(commandExecutor) + processor := NewDataProcessor() + generator := NewGenerator() + + wizard := &WizardImpl{ + data: data, + app: app, + pages: pages, + validator: validator, + scanner: scanner, + processor: processor, + generator: generator, + } + + // Create presenter with dependencies + presenter := NewPresenter(app, pages, data, wizard) + wizard.presenter = presenter + + return wizard +} + +// Run starts the initialization wizard +func (w *WizardImpl) Run() error { + // Configure logging to file + logFile, err := os.OpenFile("debug.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + defer logFile.Close() + log.SetOutput(logFile) + log.SetFlags(log.LstdFlags) + log.SetPrefix("DEBUG: ") + log.Printf("Starting initialization wizard") + + // Check existing files + filesExist := w.checkExistingFiles() + log.Printf("Checking existing files: %v", filesExist) + + // Create first page depending on state + if filesExist { + // If files already exist, show wizard for adding new node + log.Printf("Diagnostics: calling ShowAddNodeWizard, filesExist=%v", filesExist) + w.presenter.ShowAddNodeWizard(w.data) + } else { + // Иначе показываем полный мастер + log.Printf("Diagnostics: calling ShowStep1Form, filesExist=%v", filesExist) + log.Printf("Diagnostics: w.presenter=%v, w.data=%v", w.presenter, w.data) + + // Check presenter state + if w.presenter == nil { + log.Printf("CRITICAL ERROR: w.presenter is nil!") + return fmt.Errorf("presenter not initialized") + } + + // Check data state + if w.data == nil { + log.Printf("CRITICAL ERROR: w.data is nil!") + return fmt.Errorf("initialization data not initialized") + } + + log.Printf("Diagnostics: presenter and data are fine, calling ShowStep1Form") + // ShowStep1Form already creates and adds the page itself + w.presenter.ShowStep1Form(w.data) + } + + // Configure Ctrl+C handling + w.setupInputCapture() + + // Start application + log.Printf("WIZARD DIAGNOSTICS: Before app.SetRoot...") + if err := w.app.SetRoot(w.pages, true).SetFocus(w.pages).Run(); err != nil { + log.Printf("WIZARD DIAGNOSTICS: Application startup error: %v", err) + return fmt.Errorf("failed to start application: %v", err) + } + log.Printf("WIZARD DIAGNOSTICS: Application finished") + + return nil +} + +// getData returns initialization data +func (w *WizardImpl) getData() *InitData { + return w.data +} + +// getApp returns application +func (w *WizardImpl) getApp() *tview.Application { + return w.app +} + +// getPages returns pages +func (w *WizardImpl) getPages() *tview.Pages { + return w.pages +} + +// setupInputCapture configures input handling +func (w *WizardImpl) setupInputCapture() { + w.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyCtrlC { + w.app.Stop() + return nil + } + return event + }) +} + +// checkExistingFiles checks for existing configuration files +func (w *WizardImpl) checkExistingFiles() bool { + files := []string{"Chart.yaml", "values.yaml", "secrets.yaml", "talosconfig", "kubeconfig"} + for _, file := range files { + if _, err := os.Stat(file); err == nil { + return true + } + } + return false +} + +// PerformNetworkScan performs network scanning with progress +func (w *WizardImpl) PerformNetworkScan(ctx context.Context, cidr string) ([]NodeInfo, error) { + log.Printf("Starting network scan for CIDR: %s", cidr) + + // Validate CIDR + if err := w.validator.ValidateNetworkCIDR(cidr); err != nil { + return nil, fmt.Errorf("incorrect CIDR: %v", err) + } + + // Create context with timeout + scanCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + // Scan network with progress + nodes, err := w.scanner.ScanNetworkWithProgress(scanCtx, cidr, func(progress int) { + log.Printf("Scan progress: %d%%", progress) + }) + + if err != nil { + return nil, fmt.Errorf("scanning failed: %v", err) + } + + // Process scan results + processedNodes := w.processor.ProcessScanResults(nodes) + + log.Printf("Scanning completed, found %d nodes", len(processedNodes)) + return processedNodes, nil +} + +// ValidateAndProcessNodeConfig validates and processes node configuration +func (w *WizardImpl) ValidateAndProcessNodeConfig(data *InitData) error { + // Validate required fields + if err := w.validator.ValidateRequiredField(data.NodeType, "Role"); err != nil { + return err + } + + if err := w.validator.ValidateRequiredField(data.Hostname, "Hostname"); err != nil { + return err + } + + if err := w.validator.ValidateRequiredField(data.Disk, "Disk"); err != nil { + return err + } + + if err := w.validator.ValidateRequiredField(data.Interface, "Interface"); err != nil { + return err + } + + // Validate network configuration + if err := w.validator.ValidateNetworkConfig(data.Addresses, data.Gateway, data.DNSServers); err != nil { + return err + } + + // Validate VIP if specified + if err := w.validator.ValidateVIP(data.VIP); err != nil { + return err + } + + return nil +} + +// GenerateAndSaveConfig generates and saves configuration +func (w *WizardImpl) GenerateAndSaveConfig(data *InitData, isFirstNode bool) error { + log.Printf("Configuration generation and saving, first node: %v", isFirstNode) + + if isFirstNode { + // Generate full cluster configuration + if err := w.generator.GenerateBootstrapConfig(data); err != nil { + return fmt.Errorf("failed to generate cluster configuration: %v", err) + } + + // Show bootstrap prompt + w.presenter.ShowBootstrapPrompt(data, "nodes/node1.yaml") + } else { + // Update existing values.yaml + if err := w.generator.UpdateValuesYAMLWithNode(data); err != nil { + return fmt.Errorf("failed to update values.yaml: %v", err) + } + + // Generate node configuration + nodeFileName := fmt.Sprintf("nodes/node-%d.yaml", len(w.getExistingNodes())+1) + values, err := w.generator.LoadValuesYAML() + if err != nil { + return fmt.Errorf("не удалось загрузить values.yaml: %v", err) + } + + if err := w.generator.GenerateNodeConfig(nodeFileName, data, values); err != nil { + return fmt.Errorf("failed to generate node configuration: %v", err) + } + + // Show success message + w.presenter.ShowSuccessModal(fmt.Sprintf("Нода %s успешно добавлена!\n\nКонфигурация сохранена в: %s", + data.Hostname, nodeFileName)) + } + + return nil +} + +// BootstrapCluster performs cluster bootstrap +func (w *WizardImpl) BootstrapCluster() error { + log.Printf("Starting cluster bootstrap") + + w.presenter.ShowProgressModal("Выполняется bootstrap etcd...", func() { + // Load existing values.yaml + values, err := w.generator.LoadValuesYAML() + if err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Failed to load values.yaml: %v", err)) + return + } + + // Update etcdBootstrapped flag + values.EtcdBootstrapped = true + + // Save updated values.yaml + if err := w.generator.SaveValuesYAML(*values); err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Failed to save values.yaml: %v", err)) + return + } + + w.presenter.ShowSuccessModal("Кластер успешно инициализирован!\n\nСледующие шаги:\n1. Проверьте файл 'kubeconfig'\n2. Используйте 'kubectl' для управления кластером") + }) + + return nil +} + +// InitializeGenericCluster initializes generic cluster +func (w *WizardImpl) InitializeGenericCluster(data *InitData) error { + log.Printf("Generic cluster initialization") + + w.presenter.ShowProgressModal("Generic cluster initialization...", func() { + // Create necessary directories + if err := os.MkdirAll("nodes", 0755); err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Failed to create directories: %v", err)) + return + } + + // Generate Chart.yaml + chart, err := w.generator.GenerateChartYAML(data.ClusterName, data.Preset) + if err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Failed to generate Chart.yaml: %v", err)) + return + } + + if err := w.generator.SaveChartYAML(chart); err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Failed to save Chart.yaml: %v", err)) + return + } + + // Генерируем values.yaml для generic + values, err := w.generator.GenerateValuesYAML(data) + if err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Не удалось сгенерировать values.yaml: %v", err)) + return + } + + if err := w.generator.SaveValuesYAML(values); err != nil { + w.presenter.ShowErrorModal(fmt.Sprintf("Failed to save values.yaml: %v", err)) + return + } + + w.presenter.ShowSuccessModal("Generic кластер успешно инициализирован!\n\nСледующие шаги:\n1. Создайте конфигурации нод в директории 'nodes/'\n2. Выполните 'talm apply' для развертывания нод") + }) + + return nil +} + +// ProcessCozyStackNode processes node for Cozystack preset +func (w *WizardImpl) ProcessCozyStackNode(data *InitData) error { + log.Printf("Processing node for Cozystack preset") + + // For Cozystack use simplified workflow + w.presenter.ShowNodeSelection(data, "Select First Control Plane Node") + return nil +} + +// getExistingNodes gets list of existing nodes +func (w *WizardImpl) getExistingNodes() []NodeInfo { + // Load values.yaml to get list of nodes + values, err := w.generator.LoadValuesYAML() + if err != nil { + log.Printf("Failed to load values.yaml: %v", err) + return []NodeInfo{} + } + + var nodes []NodeInfo + for name, config := range values.Nodes { + nodes = append(nodes, NodeInfo{ + Name: name, + IP: config.IP, + Type: config.Type, + }) + } + + return nodes +} + +// GetValidator returns validator +func (w *WizardImpl) GetValidator() Validator { + return w.validator +} + +// GetScanner returns network scanner +func (w *WizardImpl) GetScanner() NetworkScanner { + return w.scanner +} + +// GetProcessor returns data processor +func (w *WizardImpl) GetProcessor() DataProcessor { + return w.processor +} + +// GetGenerator returns configuration generator +func (w *WizardImpl) GetGenerator() Generator { + return w.generator +} + +// GetPresenter returns presenter +func (w *WizardImpl) GetPresenter() Presenter { + return w.presenter +} + +// RunWithCustomConfig starts wizard with custom configuration +func (w *WizardImpl) RunWithCustomConfig(config InitData) error { + w.data = &config + return w.Run() +} + +// SetupLogging configures logging +func (w *WizardImpl) SetupLogging(logFile string) error { + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + + log.SetOutput(file) + log.SetFlags(log.LstdFlags) + log.SetPrefix("DEBUG: ") + + return nil +} + +// Shutdown корректно завершает работу мастера +func (w *WizardImpl) Shutdown() { + log.Printf("Shutting down initialization wizard") + if w.app != nil { + w.app.Stop() + } +} \ No newline at end of file diff --git a/internal/pkg/ui/initwizard/wizard_controller.go b/internal/pkg/ui/initwizard/wizard_controller.go new file mode 100644 index 0000000..7d3d772 --- /dev/null +++ b/internal/pkg/ui/initwizard/wizard_controller.go @@ -0,0 +1,35 @@ +package initwizard + +import ( + "fmt" + "log" +) + +type WizardController struct { + state WizardState + data *InitData +} + +// NewWizardController creates a new wizard controller +func NewWizardController(data *InitData) *WizardController { + return &WizardController{ + state: StatePreset, + data: data, + } +} + +func (c *WizardController) Transition(to WizardState) error { + log.Printf("[DEBUG-CONTROLLER] Attempting transition from %s to %s", c.state, to) + if !isAllowed(c.state, to) { + log.Printf("[DEBUG-CONTROLLER] TRANSITION FORBIDDEN: %s -> %s", c.state, to) + return fmt.Errorf( + "invalid transition %s -> %s", + c.state, + to, + ) + } + + c.state = to + log.Printf("[DEBUG-CONTROLLER] Transition completed, new state: %s", c.state) + return nil +} diff --git a/internal/pkg/ui/initwizard/wizard_state.go b/internal/pkg/ui/initwizard/wizard_state.go new file mode 100644 index 0000000..8c566ed --- /dev/null +++ b/internal/pkg/ui/initwizard/wizard_state.go @@ -0,0 +1,37 @@ +package initwizard + +type WizardState int + +const ( + StatePreset WizardState = iota + StateEndpoint + StateScanning + StateNodeSelect + StateNodeConfig + StateConfirm + StateGenerate + StateDone + StateAddNodeScan + StateCozystackScan + StateVIPConfig + StateNetworkConfig + StateNodeDetails + ) + +func (s WizardState) String() string { + return [...]string{ + "preset", + "endpoint", + "scanning", + "node_select", + "node_config", + "confirm", + "generate", + "done", + "add_node_scan", + "cozystack_scan", + "vip_config", + "network_config", + "node_details", + }[s] + } diff --git a/internal/pkg/ui/initwizard/wizard_transitions.go b/internal/pkg/ui/initwizard/wizard_transitions.go new file mode 100644 index 0000000..66bec28 --- /dev/null +++ b/internal/pkg/ui/initwizard/wizard_transitions.go @@ -0,0 +1,56 @@ +package initwizard + +var allowedTransitions = map[WizardState][]WizardState{ + StatePreset: { + StateEndpoint, + StateAddNodeScan, + }, + StateEndpoint: { + StateScanning, + StateNodeSelect, + }, + StateScanning: { + StateNodeSelect, + StateEndpoint, // cancel + }, + StateNodeSelect: { + StateNodeConfig, + StateEndpoint, + }, + StateNodeConfig: { + StateConfirm, + StateNodeSelect, + }, + StateConfirm: { + StateGenerate, + StateNodeConfig, + }, + StateGenerate: { + StateDone, + }, + StateAddNodeScan: { + StateScanning, + StateNodeSelect, + }, + StateCozystackScan: { + StateNodeSelect, + }, + StateVIPConfig: { + StateNetworkConfig, + }, + StateNetworkConfig: { + StateNodeDetails, + }, + StateNodeDetails: { + StateConfirm, + }, +} + +func isAllowed(from, to WizardState) bool { + for _, s := range allowedTransitions[from] { + if s == to { + return true + } + } + return false +} diff --git a/main.go b/main.go index c217859..20a29e1 100644 --- a/main.go +++ b/main.go @@ -88,7 +88,7 @@ func init() { // Load config after root detection (skip for init and completion commands) cmdName := cmd.Use - if !strings.HasPrefix(cmdName, "init") && !strings.HasPrefix(cmdName, "completion") { + if !strings.HasPrefix(cmdName, "init") && !strings.HasPrefix(cmdName, "completion") && !strings.HasPrefix(cmdName, "interactive") { configFile := filepath.Join(commands.Config.RootDir, "Chart.yaml") if err := loadConfig(configFile); err != nil { return fmt.Errorf("error loading configuration: %w", err) diff --git a/pkg/commands/interactive_init.go b/pkg/commands/interactive_init.go new file mode 100644 index 0000000..1a72022 --- /dev/null +++ b/pkg/commands/interactive_init.go @@ -0,0 +1,46 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +package commands + +import ( + "time" + + "github.com/cozystack/talm/internal/pkg/ui/initwizard" + "github.com/spf13/cobra" +) + +var interactiveCmdFlags struct { + interval time.Duration + configFiles []string + insecure bool +} + +// interactiveCmd starts terminal TUI for interactive configuration and apply. +var interactiveCmd = &cobra.Command{ + Use: "interactive", + Long: `Start a terminal-based UI (TUI) similar to talos-bootstrap.`, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + nodesFromArgs := len(GlobalArgs.Nodes) > 0 + endpointsFromArgs := len(GlobalArgs.Endpoints) > 0 + for _, configFile := range interactiveCmdFlags.configFiles { + if err := processModelineAndUpdateGlobals(configFile, nodesFromArgs, endpointsFromArgs, false); err != nil { + return err + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return initwizard.RunInitWizard() + }, +} + +func init() { + interactiveCmd.Flags().StringSliceVarP(&interactiveCmdFlags.configFiles, + "file", "f", nil, "specify config files with talm modeline (can specify multiple)") + interactiveCmd.Flags().DurationVarP(&interactiveCmdFlags.interval, "update-interval", "d", 3*time.Second, "interval between updates") + interactiveCmd.Flags().BoolVarP(&interactiveCmdFlags.insecure, "insecure", "i", false, "use Talos insecure maintenance mode (no talosconfig required)") + addCommand(interactiveCmd) +}