diff --git a/confs/repko.yaml b/confs/repko.yaml index d528539..6ed1639 100644 --- a/confs/repko.yaml +++ b/confs/repko.yaml @@ -94,10 +94,10 @@ sections: - "### Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее" - "## Бла бла бла бла. \n lorem ipsum прочее прочее прочее прочее" actions: - - name: "Test" + - name: "Sleep 1" type: "command" #command: "touch /etc/repko/{{.api_domain}}.txt" - command: "sleep 2" + command: "sleep 1" error: "ошибка создания файла" - name: "user" description: "Настройка данных пользователя" diff --git a/setup/condition.go b/setup/condition.go deleted file mode 100644 index 518ae85..0000000 --- a/setup/condition.go +++ /dev/null @@ -1,466 +0,0 @@ -package setup - -import ( - "bubble/setup/utils" - "bubble/types" - "errors" - "fmt" - "github.com/charmbracelet/bubbles/textinput" - "github.com/expr-lang/expr" - "github.com/sirupsen/logrus" - "io" - "io/fs" - "net/http" - "os" - "os/exec" - "reflect" - "regexp" - "strconv" - "strings" -) - -// userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000: -var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`) - -// ParamsForCondition получение словаря переменных с приведением типов для проверки условий -func ParamsForCondition(app *types.PackageApplication) map[string]interface{} { - params := make(map[string]interface{}) - - for _, variable := range app.Variables { - value := variable.Value - if value == nil { - value = variable.Default - } - - // Приводим к нужному типу, как в твоём старом коде - switch variable.Type { - case "bool": - if s, ok := value.(string); ok { - value = strings.ToLower(s) == "true" - } - case "number", "int", "float": - if s, ok := value.(string); ok { - if n, err := strconv.ParseFloat(s, 64); err == nil { - value = n - } - } - } - - params[variable.Name] = value - } - - // Также добавляем значения из полей секций (на случай, если переменная используется только в поле) - for _, section := range app.Sections { - for _, field := range section.Fields { - if field.Variable == nil || !field.Active { - continue - } - value := field.Variable.Value - if value == nil { - value = field.Variable.Default - } - - switch field.Variable.Type { - case "bool": - if s, ok := value.(string); ok { - value = strings.ToLower(s) == "true" - } - case "number", "int", "float": - if s, ok := value.(string); ok { - if n, err := strconv.ParseFloat(s, 64); err == nil { - value = n - } - } - } - - params[field.VariableName] = value - } - } - - return params -} - -// CheckCondition проверяет равно ли переданное условие -func CheckCondition(condition string, app *types.PackageApplication) bool { - if condition == "" { - return true - } - - result, err := expr.Eval(condition, ParamsForCondition(app)) - - if err != nil { - logrus.Errorf("Ошибка в условии '%s': %v", condition, err) - return false - } - - boolResult, ok := result.(bool) - if !ok { - logrus.Warnf("Условие '%s' не вернуло bool, а %T: %v", condition, result, result) - return false - } - - return boolResult -} - -// ParamsForCompute получение словаря переменных без приведения типов, для вычисления -func ParamsForCompute(app *types.PackageApplication) map[string]interface{} { - params := make(map[string]interface{}) - - for i := range app.Variables { - v := &app.Variables[i] - value := v.Value - if value == nil { - value = v.Default - } - params[v.Name] = value - } - - // Добавляем значения из активных полей - for _, section := range app.Sections { - for _, field := range section.Fields { - if field.Variable == nil || !field.Active { - continue - } - value := field.Variable.Value - if value == nil { - value = field.Variable.Default - } - params[field.VariableName] = value - } - } - - return params -} - -// ComputeValue вычисляет значение для поля compute. -func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) { - if computeExpr == "" { - return nil, nil - } - - env := ParamsForCompute(app) - options := append([]expr.Option{ - expr.Env(env), - expr.AsKind(reflect.String), // по умолчанию строка, если не указано иное - expr.AllowUndefinedVariables(), - }, DefineExprCustomFunctions()...) - - program, err := expr.Compile(computeExpr, options...) - if err != nil { - return nil, fmt.Errorf("ошибка компиляции compute: %v", err) - } - - result, err := expr.Run(program, env) - if err != nil { - return nil, fmt.Errorf("ошибка выполнения compute: %v", err) - } - - return result, nil -} - -// Recompute производит перерасчет всех полей -func Recompute(app *types.PackageApplication) { - for i := range app.Sections { - section := &app.Sections[i] - for j := range section.Fields { - field := §ion.Fields[j] - if field.FieldType == "compute" && field.Compute != "" { - value, err := ComputeValue(field.Compute, app) - if err != nil { - value = fmt.Sprintf("Ошибка вычисления: %v", err) - } - field.Variable.Value = value - } - } - } -} - -func ValidateCheckField(fieldName, variableName string, validations []types.FieldValidate, app *types.PackageApplication) types.BoolValidateFunc { - if len(validations) == 0 { - return nil - } - return func(checked bool) error { - env := ParamsForCompute(app) - env[variableName] = checked - options := append([]expr.Option{ - expr.Env(env), - expr.AllowUndefinedVariables(), - }, DefineExprCustomFunctions()...) - for _, validate := range validations { - program, err := expr.Compile(validate.Rule, options...) - prefix := fmt.Sprintf("Валидация поля %s,", fieldName) - if err != nil { - msg := fmt.Errorf("ошибка компиляции правила: %s, err: %v", validate.Rule, err) - logrus.Errorf("%s %v", prefix, msg) - return msg - } - result, err := expr.Run(program, env) - if err != nil { - msg := fmt.Errorf("ошибка выполнения правила: %s, err: %v", validate.Rule, err) - logrus.Errorf("%s %v", prefix, msg) - return msg - } - if boolValue, ok := result.(bool); ok && !boolValue { - return fmt.Errorf("%s", validate.Message) - } else if !ok { - msg := fmt.Errorf("возвращаемое значение правила: %s не булево", validate.Rule) - logrus.Errorf("%s %v", prefix, msg) - return msg - } - } - return nil - - } - -} - -// ValidateField создает функцию типа textinput.ValidateFunc для проверки значения полей -func ValidateField(fieldName, variableName string, validations []types.FieldValidate, app *types.PackageApplication) textinput.ValidateFunc { - if len(validations) == 0 { - return nil - } - return func(text string) error { - env := ParamsForCompute(app) - env[variableName] = text - options := append([]expr.Option{ - expr.Env(env), - expr.AllowUndefinedVariables(), - }, DefineExprCustomFunctions()...) - for _, validate := range validations { - program, err := expr.Compile(validate.Rule, options...) - prefix := fmt.Sprintf("Валидация поля %s,", fieldName) - if err != nil { - msg := fmt.Errorf("ошибка компиляции правила: %s, err: %v", validate.Rule, err) - logrus.Errorf("%s %v", prefix, msg) - return msg - } - result, err := expr.Run(program, env) - if err != nil { - msg := fmt.Errorf("ошибка выполнения правила: %s, err: %v", validate.Rule, err) - logrus.Errorf("%s %v", prefix, msg) - return msg - } - if boolValue, ok := result.(bool); ok && !boolValue { - return fmt.Errorf("%s", validate.Message) - } else if !ok { - msg := fmt.Errorf("возвращаемое значение правила: %s не булево", validate.Rule) - logrus.Errorf("%s %v", prefix, msg) - return msg - } - } - return nil - } -} - -// DefineExprCustomFunctions возвращает собственные функции expr -func DefineExprCustomFunctions() []expr.Option { - return []expr.Option{ - //вычисляет значение по заданному регулярному выражению - expr.Function("regex", - func(args ...interface{}) (interface{}, error) { - if len(args) != 3 { - return "", fmt.Errorf("regex() ожидает 3 аргумента") - } - input := fmt.Sprintf("%v", args[0]) - pattern := fmt.Sprintf("%v", args[1]) - repl := fmt.Sprintf("%v", args[2]) - re, err := regexp.Compile(pattern) - if err != nil { - return input, nil - } - return re.ReplaceAllString(input, repl), nil - }, new(func(string, string, string) string), - ), - //проверяет совпадение переданного значения и указанного регулярного выражения - expr.Function("regex_match", - func(args ...interface{}) (interface{}, error) { - if len(args) != 2 { - return false, fmt.Errorf("regex_match() ожидает 2 аргумента") - } - input := fmt.Sprintf("%v", args[0]) - pattern := fmt.Sprintf("%v", args[1]) - - re, err := regexp.Compile(pattern) - if err != nil { - return false, fmt.Errorf("некоректный regex %s, err:%v", pattern, err) - } - return re.MatchString(input), nil - - }, new(func(string, string) bool), - ), - //возвращает собственный текст ошибки - expr.Function("error", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return nil, fmt.Errorf("error() требует сообщения") - } - return nil, fmt.Errorf("%v", args[0]) - }, - new(func(string) error), - ), - //возвращает тело ответа по указанному адресу - expr.Function("http_get", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return "", nil - } - url := fmt.Sprintf("%v", args[0]) - resp, err := http.Get(url) - if err != nil { - return "", err - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - return string(body), nil - }, - new(func(string) string), - ), - //возвращает результат выполнения команды из оболочки - expr.Function("shell", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return "", nil - } - cmd := fmt.Sprintf("%v", args[0]) - parts := strings.Fields(cmd) - if len(parts) == 0 { - return "", nil - } - out, err := exec.Command(parts[0], parts[1:]...).CombinedOutput() - if err != nil { - return "", err - } - return strings.TrimSpace(string(out)), nil - }, - new(func(string) error), - ), - //возвращает значение указанной переменной из env - expr.Function("env", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return "", nil - } - return os.Getenv(fmt.Sprintf("%v", args[0])), nil - }, new(func(string) error), - ), - //проверяет наличие файла в системе - expr.Function("file_exists", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return false, nil - } - path := fmt.Sprintf("%v", args[0]) - fileInfo, err := os.Stat(path) - if err == nil && !fileInfo.IsDir() { - return true, nil - } else if err == nil && fileInfo.IsDir() { - return false, fmt.Errorf("file is dir") - } - if errors.Is(err, fs.ErrNotExist) { - return false, fmt.Errorf("no such directory or file") - } else { - return false, err - } - }, new(func(string) bool), - ), - //проверяет наличие директории в системе - expr.Function("dir_exists", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return false, nil - } - path := fmt.Sprintf("%v", args[0]) - fileInfo, err := os.Stat(path) - if err == nil && fileInfo.IsDir() { - return true, nil - } else if err == nil && !fileInfo.IsDir() { - return false, fmt.Errorf("file is not a dir") - } - if errors.Is(err, fs.ErrNotExist) { - return false, fmt.Errorf("no such directory or file") - } else { - return false, err - } - }, new(func(string) bool), - ), - //проверяет доступность порта - expr.Function("port_free", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return false, nil - } - port := fmt.Sprintf("%v", args[0]) - cmd := exec.Command("ss", "-ltn", "sport =: "+port) - return cmd.Run() != nil, nil - }, new(func(string) error), - ), - //проверяет наличие пользователя в системе - expr.Function("user_exists", - func(args ...interface{}) (interface{}, error) { - if len(args) == 0 { - return false, nil - } - name := fmt.Sprintf("%v", args[0]) - cmd := exec.Command("id", "-u", name) - return cmd.Run() == nil, nil - }, new(func(string) bool), - ), - } -} - -// SpecialValueHandle получаем необычные значения полей -func SpecialValueHandle(special string) interface{} { - specialValue := strings.Split(special, "code:")[1] - switch specialValue { - case "users": - return GetSystemUsers() - } - return true -} - -// GetSystemUsers получение списка обычных пользователей в системе -func GetSystemUsers() []string { - //результат выполнения команды читающей файл passwd - output, _ := exec.Command("getent", "passwd").Output() - //массив строк разделенных новой строкой - lines := strings.Split(string(output), "\n") - var users []string - for _, line := range lines { - //линия совпадает с регулярным выражением - if matches := userRE.FindStringSubmatch(line); len(matches) == 3 { - //идентификатор больше или равен 1000, uid обычного пользователя в системе - if uid, _ := strconv.Atoi(matches[2]); uid >= 1000 { - users = append(users, matches[1]) - } - } - } - return users -} - -// ExecuteAction выполняет указанное действие с использованием шаблонов из expr и картой параметров -func ExecuteAction(action *types.SectionAction, application *types.PackageApplication) error { - //Карта параметров - vars := ParamsForCondition(application) - switch action.Type { - case "command": - //Формируем строку команды по шаблону с подставленными значениями и передаем её для выполнения - cmdStr, cmdErr := utils.RenderTemplate(action.Command, vars) - if cmdErr != nil { - return fmt.Errorf("не удалось создать шаблон команды: %v", cmdErr) - } - return utils.RunCommand(cmdStr, action.RunAs) - case "file_template": - //Формируем 2 строки пути, с подставленными значениями и передаем её для выполнения - source, sourceErr := utils.RenderTemplate(action.FileSource, vars) - if sourceErr != nil { - return fmt.Errorf("не удалось создать шаблон файла источника: %v", sourceErr) - } - target, targetErr := utils.RenderTemplate(action.FileTarget, vars) - if targetErr != nil { - return fmt.Errorf("не удалось создать шаблон файла цели: %v", targetErr) - } - return utils.RenderFileTemplate(source, target, vars) - default: - return fmt.Errorf("действие для выполнения '%s' не опознано", action.Type) - } -} diff --git a/setup/fields/checkbox.go b/setup/fields/checkbox.go index 11350c7..406dc74 100644 --- a/setup/fields/checkbox.go +++ b/setup/fields/checkbox.go @@ -37,8 +37,8 @@ func (m *CheckboxModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { case tea.KeyMsg: //Отслеживает нажатия клавиш. switch msg.String() { - case " ", "enter": - //Если был нажат enter или space, то меняем текущее значение на противоположное + case "enter": + //Если был нажат enter, то меняем текущее значение на противоположное m.checked = !m.checked m.CheckboxValidate() } diff --git a/setup/fields/dirSelect.go b/setup/fields/dirSelect.go index bb4ca59..a7235ce 100644 --- a/setup/fields/dirSelect.go +++ b/setup/fields/dirSelect.go @@ -138,7 +138,7 @@ func (m *DirSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { cmds = append(cmds, cmd) } } - return m, tea.Batch(cmds...) + return m, tea.Sequence(cmds...) } func (m *DirSelectModel) View() string { diff --git a/setup/fields/fileSelect.go b/setup/fields/fileSelect.go index 043b585..540de0c 100644 --- a/setup/fields/fileSelect.go +++ b/setup/fields/fileSelect.go @@ -139,7 +139,7 @@ func (m *FileSelectModel) Update(msg tea.Msg) (FieldModel, tea.Cmd) { cmds = append(cmds, cmd) } } - return m, tea.Batch(cmds...) + return m, tea.Sequence(cmds...) } func (m *FileSelectModel) View() string { diff --git a/setup/form.go b/setup/form.go index 7450a0b..6a36191 100644 --- a/setup/form.go +++ b/setup/form.go @@ -3,6 +3,7 @@ package setup import ( "bubble/setup/fields" "bubble/setup/ui" + "bubble/setup/utils" "bubble/types" "fmt" "github.com/charmbracelet/bubbles/viewport" @@ -31,7 +32,7 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt //Заполняем массив моделей полей с учетом условий var fieldModels []fields.FieldModel for _, sectionField := range currentSection.Fields { - if !CheckCondition(sectionField.Condition, application) { + if !utils.CheckCondition(sectionField.Condition, application) { continue } switch sectionField.FieldType { @@ -41,13 +42,13 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt options = append(options, fmt.Sprintf("%v", sectionField.Options[j])) } if sectionField.SpecialValue != "" { - specialOptions := SpecialValueHandle(sectionField.SpecialValue) + specialOptions := utils.SpecialValueHandle(sectionField.SpecialValue) if strSpecial, ok := specialOptions.([]string); ok { options = strSpecial } } selected := fmt.Sprintf("%v", sectionField.Variable.Value) - validationFunc := ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) + validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) fieldModels = append(fieldModels, fields.NewSelectModel(sectionField.Name, selected, sectionField.Description, options, validationFunc)) case "check": checked := false @@ -56,23 +57,23 @@ func NewFormModel(application *types.PackageApplication, sectionIdx, focus, widt } else { checked = sectionField.Variable.Value == "true" } - validationFunc := ValidateCheckField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) + validationFunc := utils.ValidateCheckField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) fieldModels = append(fieldModels, fields.NewCheckboxModel(sectionField.Name, sectionField.Description, checked, validationFunc)) case "compute": - value, err := ComputeValue(sectionField.Compute, application) + value, err := utils.ComputeValue(sectionField.Compute, application) if err != nil { value = fmt.Sprintf("Ошибка вычисления: %v", err) } fieldModels = append(fieldModels, fields.NewComputeModel(sectionField.Name, fmt.Sprintf("%v", value), sectionField.Description)) //compute расчет case "file": - validationFunc := ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) + validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) fieldModels = append(fieldModels, fields.NewFileSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, sectionField.FileFieldExtensions, validationFunc)) case "directory": - validationFunc := ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) + validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) fieldModels = append(fieldModels, fields.NewDirSelectModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, validationFunc)) default: - validationFunc := ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) + validationFunc := utils.ValidateField(sectionField.Name, sectionField.VariableName, sectionField.Validate, application) fieldModels = append(fieldModels, fields.NewInputModel(sectionField.Name, fmt.Sprintf("%v", sectionField.Variable.Value), sectionField.Description, validationFunc)) } } @@ -165,7 +166,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // фокус равен количество полей, значит он на кнопке следующей секции, // если меньше количества полей или равен 0, значит фокус на поле формы. // Если у нас нет полей на форме (секция About), значит количество полей равно 0, а фокус по умолчанию устанавливается 0. - // Чтобы m.FieldModels[m.focus] вызвало ошибку "индекс вне массива", + // Чтобы m.FieldModels[m.focus] не вызвало ошибку "индекс вне массива", // нужно чтобы в условие m.focus < len(m.FieldModels) попал фокус меньше 0 (-1,-2 и тд). // Чтобы этого избежать, есть проверка, которая устанавливает m.focus на последнюю позицию (количество полей на форме). // Чтобы эта проверка дала сбой, нужно после неё прописать изменение фокуса формы на отрицательный принудительно, так как такое не может произойти в текущем коде. @@ -222,7 +223,7 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var viewportCmd, fieldsCmd tea.Cmd m.viewport, viewportCmd = m.viewport.Update(msg) fieldsCmd = m.updateFields(msg) - return m, tea.Batch(viewportCmd, fieldsCmd) + return m, tea.Sequence(viewportCmd, fieldsCmd) } func (m *FormModel) View() string { @@ -407,5 +408,5 @@ func (m *FormModel) updateFields(msg tea.Msg) tea.Cmd { } } //Объединяем весь массив команду в одну - return tea.Batch(cmds...) + return tea.Sequence(cmds...) } diff --git a/setup/help.go b/setup/help.go new file mode 100644 index 0000000..33291cb --- /dev/null +++ b/setup/help.go @@ -0,0 +1,99 @@ +package setup + +import "github.com/charmbracelet/bubbles/key" + +//todo интегрировать нормально клавиши + +// MainKeyMap структура главных клавиш +type MainKeyMap struct { + Help key.Binding //Клавиша помощи + Confirm key.Binding //Клавиша подтверждения + Exit key.Binding //Клавиша выхода +} + +// ArrowKeyMap структура навигации +type ArrowKeyMap struct { + Up key.Binding //Клавиша перемещения наверх + Down key.Binding //Клавиша перемещения вниз + Left key.Binding //Клавиша перемещения влево + Right key.Binding //Клавиша перемещения вниз + Top key.Binding //Клавиша перемещения в самый верх + Bottom key.Binding //Клавиша перемещения в самый низ +} + +// FormKeyMap структура клавиш формы +type FormKeyMap struct { + MainKeyMap //Главные клавиши + ArrowKeyMap //Клавиши навигации + LogWindow key.Binding //Клавиша окна логирования + Close key.Binding //Клавиша закрытия +} + +// ListKeyMap структура клавиш списка приложений +type ListKeyMap struct { + MainKeyMap //Главные клавиши + ArrowKeyMap //Клавиши навигации +} + +type BaseKeyMap struct { + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Confirm key.Binding + Help key.Binding + Close key.Binding + Quit key.Binding + Log key.Binding +} + +func (k BaseKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Help, k.Log, k.Close, k.Quit} +} + +func (k BaseKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Right, k.Left}, + {k.Confirm, k.Help, k.Log}, + {k.Close, k.Quit}, + } +} + +var MainKeys = MainKeyMap{ + Help: key.NewBinding(key.WithKeys("?", "ctrl+h"), key.WithHelp("?/ctrl+h", "help")), + Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + Exit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "exit")), +} + +var ArrowKeys = ArrowKeyMap{ + Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "move up")), + Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "move down")), + Left: key.NewBinding(key.WithKeys("left"), key.WithHelp("left", "move left")), + Right: key.NewBinding(key.WithKeys("right"), key.WithHelp("right", "move right")), + Top: key.NewBinding(key.WithKeys("home"), key.WithHelp("home", "to the top")), + Bottom: key.NewBinding(key.WithKeys("end"), key.WithHelp("end", "to the end")), +} + +var FormKeys = FormKeyMap{ + MainKeyMap: MainKeys, + ArrowKeyMap: ArrowKeys, + LogWindow: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("ctrl+l", "show/hide logs")), + Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close select/close app setting")), +} + +var ListKeys = ListKeyMap{ + MainKeyMap: MainKeys, + ArrowKeyMap: ArrowKeys, +} + +var keys = BaseKeyMap{ + Up: key.NewBinding(key.WithKeys("up"), key.WithHelp("up", "move up")), + Down: key.NewBinding(key.WithKeys("down"), key.WithHelp("down", "move down")), + Left: key.NewBinding(key.WithKeys("left"), key.WithHelp("left", "move left")), + Right: key.NewBinding(key.WithKeys("right"), key.WithHelp("right", "move right")), + Confirm: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm")), + Help: key.NewBinding(key.WithKeys("?", "ctrl+h"), key.WithHelp("?/ctrl+h", "help")), + Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")), + Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")), + Log: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("ctrl+l", "show/hide log window")), +} diff --git a/setup/list.go b/setup/list.go index 7583b28..04900ee 100644 --- a/setup/list.go +++ b/setup/list.go @@ -4,6 +4,7 @@ import ( "bubble/setup/ui" "bubble/types" "fmt" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -76,9 +77,9 @@ func (m *ListApplicationsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: //Отслеживаем нажатия клавиш списка - switch msg.String() { - case "enter": - //Обработчик enter нужен для подтверждения элемента списка. + switch { + case key.Matches(msg, keys.Confirm): + //Обработчик выбора нужен для подтверждения элемента списка. //Получаем информацию о выбранном приложении, //возвращаем команду по возврату сообщения о показе модального окна подтверждения настройки приложения. selected := m.List.SelectedItem().(ListApplicationItem) @@ -102,6 +103,7 @@ func (m *ListApplicationsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + } //Если ранее не было возвращено команд, то передаем сообщение напрямую в список m.List, cmd = m.List.Update(msg) diff --git a/setup/main.go b/setup/main.go index c5926dc..9281a82 100644 --- a/setup/main.go +++ b/setup/main.go @@ -2,23 +2,25 @@ package setup import ( "bubble/bootstrap" + "bubble/setup/utils" "bubble/types" "fmt" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/sirupsen/logrus" - "os/exec" ) func Start() { + //Создаем новый объект Main для настройки. m := NewMain() - p := tea.NewProgram(m, tea.WithAltScreen()) - m.p = p + p := tea.NewProgram(m, tea.WithAltScreen()) //Запускаем через bubble tea программу + m.Program = p //Указываем объекту настроек, программу в которой запущен, чтобы передавать туда сигналы if _, err := p.Run(); err != nil { panic(err) } } -// todo подсказка горячие клавиши // Main структура описывающая объект настроек type Main struct { current tea.Model //Текущая модель для отображения @@ -31,7 +33,9 @@ type Main struct { CurrentSectionIdx int //Индекс текущей секции Width int //Ширина окна Height int //Высота окна - p *tea.Program + Program *tea.Program //Программа для отправки данных между собой + Keys BaseKeyMap + help help.Model } // NewMain создаем новый объект настройки @@ -41,7 +45,10 @@ func NewMain() *Main { m := &Main{ Applications: apps, CurrentSectionIdx: 0, + Keys: keys, + help: help.New(), } + m.help.ShowAll = false //Записываем в текущую модель список приложений m.current = NewListApplicationsModel(m.Applications, m.Width, m.Height) m.log = NewLogModel(m.Width, m.Height) @@ -56,8 +63,9 @@ func (m *Main) Init() tea.Cmd { func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd - //Если у нас установлен модель модального окна, то передаем сообщение в его функцию обновления, + //Если у нас установлена модель модального окна, то передаем сообщение в его функцию обновления, //при возврате команды модального окна записываем её в массив команд. + //Если у нас сейчас показывается окно логирования, то обновляем его модель. //В противном случае передаем сообщение в функцию обновления текущей модели, //при возврате команды также записываем её в массив команд. if m.overlay != nil { @@ -65,7 +73,7 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { cmds = append(cmds, cmd) } - } else if m.showLog { //todo комментарии + } else if m.showLog { m.log, cmd = m.log.Update(msg) if cmd != nil { cmds = append(cmds, cmd) @@ -92,12 +100,15 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case tea.KeyMsg: //Отслеживаем нажатие клавиш приложения - switch msg.String() { - case "ctrl+l": - //Обработчик клавиш ctrl+l показывает/скрывает окно логирования действий + switch { + case key.Matches(msg, m.Keys.Help): + //Обработчик клавиш помощи, показывает/скрывает полное меню подсказок + m.help.ShowAll = !m.help.ShowAll + case key.Matches(msg, m.Keys.Log): + //Обработчик клавиш логов показывает/скрывает окно логирования действий m.showLog = !m.showLog - case "ctrl+c": - //Обработчик клавиш ctrl+c у приложения нужен для его закрытия. + case key.Matches(msg, m.Keys.Quit): + //Обработчик клавиш закрытия приложения. //Возвращаем команду по возврату сообщения о показе модального окна return m, func() tea.Msg { return types.ShowModalMsg{ @@ -119,19 +130,28 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case types.NextSectionMsg: //Получаем сообщение о переходе на следующую секцию настраиваемого приложения. - //Добавляем команду для возврата сообщения показа модального окна о выполнении действий секции - //todo добавить проверку на наличие действий, если действий нет, просто переходить к следующей секции, выдавать сообщение "Действий секции нет" - cmds = append(cmds, func() tea.Msg { - return types.ShowModalMsg{ - Type: types.ModalInfo, - Title: "Выполнение действий", - Message: "Ожидайте завершения действий секции", - ModalButtons: nil, - OnResult: nil, + //Если у текущей секции есть действия, то добавляем в массив команд функцию возврата сообщения показа модального окна с информацией о запуске действий. + //Запускаем функцию выполнения действий секции в горутине. + //Если действий у текущей секции нет, то просто меняем секцию на следующую + if len(m.CurrentSection.Actions) > 0 { + cmds = append(cmds, func() tea.Msg { + return types.ShowModalMsg{ + Type: types.ModalLoading, + Title: "Выполнение действий", + Message: "Ожидайте завершения действий секции", + ModalButtons: nil, + OnResult: nil, + } + }) + //Запуск функции выполнения действий в горутине + go m.executeSectionActions() + } else { + if ok, modalMsg := m.NextSectionProcess(); !ok { + return m, func() tea.Msg { + return modalMsg + } } - }) - //Запуск функции выполнения действий в горутине - go m.executeSectionActions() + } case types.ActionsCompleteMsg: //Команда закрытия модального окна cmds = append(cmds, func() tea.Msg { @@ -142,45 +162,12 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { logModel.logs = append(logModel.logs, msg.Log...) m.log, cmd = logModel.Update(msg) if msg.Success { - //Если действие успешно, переходим к следующей секции. - //Ищем индекс следующей секции, если он остался 0, то следующие секции недоступны. - //Возвращаем модальное окно с выходом к списку приложений - nextSectionIndex := 0 - for sectionIdx := m.CurrentSectionIdx + 1; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ { - section := m.CurrentApplication.Sections[sectionIdx] - if CheckCondition(section.Condition, m.CurrentApplication) { - nextSectionIndex = sectionIdx - break - } - } - if nextSectionIndex == 0 { + //Если действие успешно, то запускаем функцию перехода на следующую секцию. + if ok, modalMsg := m.NextSectionProcess(); !ok { return m, func() tea.Msg { - return types.ShowModalMsg{ - Type: types.ModalWarning, - Title: "Ошибка секций приложения", - Message: "Указанное приложение не имеет больше доступных секций для обработки", - ModalButtons: []types.ModalButton{{ - Text: "Вернутся", - Value: true, - }}, - OnResult: func(result any) tea.Cmd { - return func() tea.Msg { - return types.BackToListMsg{} - } - }, - } + return modalMsg } } - - //Если следующая секция доступна записываем секцию и её индекс. - //Выводим либо финальную форму, если секция последняя и её тип SectionFinal, либо следующую простую секцию. - m.CurrentSectionIdx = nextSectionIndex - m.CurrentSection = &m.CurrentApplication.Sections[nextSectionIndex] - if nextSectionIndex == len(m.CurrentApplication.Sections)-1 && m.CurrentSection.Type == types.SectionFinal { - m.current = NewFinalModel(m.CurrentApplication.Variables, m.Width, m.Height) - } else { - m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0, m.Width, m.Height) - } } else { //Если действие не удалось выполнить показываем модель логов принудительно, //также возвращаем команду возврата сообщения показа модального окна @@ -207,7 +194,7 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //ищем доступную секцию for sectionIdx := 0; sectionIdx < len(m.CurrentApplication.Sections); sectionIdx++ { section := m.CurrentApplication.Sections[sectionIdx] - if CheckCondition(section.Condition, m.CurrentApplication) { + if utils.CheckCondition(section.Condition, m.CurrentApplication) { m.CurrentSectionIdx = sectionIdx m.CurrentSection = §ion break @@ -243,19 +230,24 @@ func (m *Main) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case types.ShowModalMsg: //Получаем сообщение о показе модального окна. //В поле модального окна записываем новый объект модального окна на основе сообщений. + //Инициализируем модальное окно, если оно вернуло команду, добавляем её в массив. //НЕ СТИРАЕМ ТЕКУЩЕЕ ПРИЛОЖЕНИЕ m.overlay = NewModalModel(msg, m.Width, m.Height) - return m, nil + initModalCmd := m.overlay.Init() + if initModalCmd != nil { + cmds = append(cmds, initModalCmd) + } + return m, tea.Sequence(cmds...) case types.CloseModalMsg: //Получаем сообщение о закрытии модального окна. //Стираем модальное окно. m.overlay = nil return m, nil //предохранитель - //return m,tea.Batch(cmds...) + //return m,tea.Sequence(cmds...) } //Возвращаем команду собранную из массива команд текущей модели - return m, tea.Batch(cmds...) + return m, tea.Sequence(cmds...) } // View формирует строку на основе модального окна или текущей модели @@ -267,55 +259,22 @@ func (m *Main) View() string { if m.overlay != nil { view = m.overlay.View() } - return view + return view + "\n\n" + m.help.View(m.Keys) } // Sugar проверяет поле указанное в сообщение на важность в форме и приложении. func (m *Main) Sugar(sugar types.SugarMsg) { //Если полей с указанной переменной несколько, то выключаем все прочие поля кроме, указанного в сообщении - if m.IsMultiField(sugar.VariableName) { + if utils.IsMultiField(sugar.VariableName, m.CurrentApplication.Sections) { m.DeactivateField(sugar.VariableName, sugar.FieldName) } //Если от переменной поля зависит секции или поля, //то формируем новую форму с учетом прошлого фокуса - if IsParent(sugar.VariableName, m.CurrentApplication.Sections) { + if utils.IsParent(sugar.VariableName, m.CurrentApplication.Sections) { m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, sugar.Focus, m.Width, m.Height) } } -// IsParent проверяет, является ли указанная переменная условием для поля или секции -func IsParent(variableName string, sections []types.Section) bool { - for _, section := range sections { - for _, parent := range section.Parent { - if parent == variableName { - return true - } - } - for _, field := range section.Fields { - for _, parent := range field.Parent { - if parent == variableName { - return true - } - } - - } - } - return false -} - -// IsMultiField проверяет переменную на использование несколькими полями. -func (m *Main) IsMultiField(variableName string) bool { - var count int - for _, section := range m.CurrentApplication.Sections { - for _, field := range section.Fields { - if field.Name == variableName { - count++ - } - } - } - return count > 1 -} - // DeactivateField отключает все поля использующие указанную переменную, кроме поля с указанным именем func (m *Main) DeactivateField(variableName, fieldName string) { for sectionIdx, section := range m.CurrentApplication.Sections { @@ -340,14 +299,14 @@ func (m *Main) executeSectionActions() { logBuffer = append(logBuffer, sectionLog) for _, action := range m.CurrentSection.Actions { //Если условие для действия не выполняется, то пропускаем - if !CheckCondition(action.Condition, m.CurrentApplication) { + if !utils.CheckCondition(action.Condition, m.CurrentApplication) { return } sectionActionLog := fmt.Sprintf("Выполнение действия: %s", action.Name) logrus.Infof(sectionActionLog) logBuffer = append(logBuffer, sectionActionLog) //Проверяем, что все нужные пакеты для действия установлены - if err := m.InstallPackage(action.RequirePackages); err != nil { + if err := utils.InstallPackages(action.RequirePackages); err != nil { errLog := fmt.Sprintf("Ошибка установки пакета: %v", err) logrus.Errorf(errLog) logBuffer = append(logBuffer, errLog) @@ -355,7 +314,7 @@ func (m *Main) executeSectionActions() { break } //Выполняем действие - if err := ExecuteAction(&action, m.CurrentApplication); err != nil { + if err := utils.ExecuteAction(&action, m.CurrentApplication); err != nil { errLog := fmt.Sprintf("Ошибка выполнения действия: %v", err) logrus.Errorf(errLog) logBuffer = append(logBuffer, errLog) @@ -367,23 +326,47 @@ func (m *Main) executeSectionActions() { logrus.Infof(successMsg) } //Отправляем в программу сигнал об успешном завершении действия - m.p.Send(types.ActionsCompleteMsg{ + m.Program.Send(types.ActionsCompleteMsg{ Success: success, Log: logBuffer, }) } -// InstallPackage проверяет установлен ли пакет, устанавливает при отсутствии -func (m *Main) InstallPackage(packages []string) error { - for _, pkg := range packages { - cmd := exec.Command("dpkg", "-s", pkg) - if err := cmd.Run(); err != nil { - installCmd := exec.Command("apt", "install", "-y", pkg) - out, err := installCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("пакет %s: %v\nOutput: %s", pkg, err, out) - } +// NextSectionProcess проверяет доступность следующей секции для настройки +func (m *Main) NextSectionProcess() (bool, types.ShowModalMsg) { + //Ищем индекс следующей секции, если он остался 0, то следующие секции недоступны. + //Возвращаем модальное окно с выходом к списку приложений + nextSectionIndex := 0 + for sectionIDx := m.CurrentSectionIdx + 1; sectionIDx < len(m.CurrentApplication.Sections); sectionIDx++ { + section := m.CurrentApplication.Sections[sectionIDx] + if utils.CheckCondition(section.Condition, m.CurrentApplication) { + nextSectionIndex = sectionIDx + break } } - return nil + if nextSectionIndex == 0 { + return false, types.ShowModalMsg{ + Type: types.ModalWarning, + Title: "Ошибка секций приложения", + Message: "Указанное приложение не имеет больше доступных секций для обработки", + ModalButtons: []types.ModalButton{ + {Text: "Вернуться", Value: true}, + }, + OnResult: func(result any) tea.Cmd { + return func() tea.Msg { + return types.BackToListMsg{} + } + }, + } + } + //Если следующая секция доступна записываем секцию и её индекс. + //Выводим либо финальную форму, если секция последняя и её тип SectionFinal, либо следующую простую секцию. + m.CurrentSectionIdx = nextSectionIndex + m.CurrentSection = &m.CurrentApplication.Sections[nextSectionIndex] + if nextSectionIndex == len(m.CurrentApplication.Sections)-1 && m.CurrentSection.Type == types.SectionFinal { + m.current = NewFinalModel(m.CurrentApplication.Variables, m.Width, m.Height) + } else { + m.current = NewFormModel(m.CurrentApplication, m.CurrentSectionIdx, 0, m.Width, m.Height) + } + return true, types.ShowModalMsg{} } diff --git a/setup/modal.go b/setup/modal.go index 83729d0..a7eddda 100644 --- a/setup/modal.go +++ b/setup/modal.go @@ -3,6 +3,7 @@ package setup import ( "bubble/setup/ui" "bubble/types" + "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) @@ -17,11 +18,12 @@ type ModalModel struct { width int //Ширина окна height int //Высота окна onResult func(result any) tea.Cmd //Какое действие надо выполнить при нажатии + spinner spinner.Model //Для демонстрации прогресса выполнения действий } // NewModalModel создает новую модель модального окна на основе переданного сообщения func NewModalModel(msg types.ShowModalMsg, width, height int) *ModalModel { - return &ModalModel{ + m := &ModalModel{ modalType: msg.Type, title: msg.Title, message: msg.Message, @@ -31,14 +33,25 @@ func NewModalModel(msg types.ShowModalMsg, width, height int) *ModalModel { height: height, onResult: msg.OnResult, } + if msg.Type == types.ModalLoading { + s := spinner.New() + s.Spinner = spinner.Line + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) + m.spinner = s + } + return m } func (m *ModalModel) Init() tea.Cmd { + if m.modalType == types.ModalLoading { + return m.spinner.Tick + } return nil } // Update отслеживает действия с модальным окном func (m *ModalModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: //Отслеживаем и записываем новые размеры окна @@ -64,24 +77,28 @@ func (m *ModalModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.focus >= len(m.buttons) { m.focus = 0 } - case "enter", " ": + case "enter": //Обработчик клавиши enter нужен для подтверждения кнопки. - //Получаем значение кнопки, а после если у модального окна прописана функция, + //Если у модального окна есть кнопки. + //Получаем значение нажатой кнопки, а после если у модального окна прописана функция, //то передаем значение кнопки в функцию модального окна. - result := m.buttons[m.focus].Value - var cmd tea.Cmd - if m.onResult != nil { - cmd = m.onResult(result) + if len(m.buttons) > 0 { + result := m.buttons[m.focus].Value + if m.onResult != nil { + cmd = m.onResult(result) + } + //Возвращаем единую команду составленную из двух, + //результата выполнения функции модального окна, + //функцию возвращения сообщения о закрытии модального окна. + return m, tea.Sequence(cmd, func() tea.Msg { return types.CloseModalMsg{} }) } - //Возвращаем единую команду составленную из двух, - //результата выполнения функции модального окна, - //функцию возвращения сообщения о закрытии модального окна. - return m, tea.Batch( - cmd, - func() tea.Msg { return types.CloseModalMsg{} }, - ) } } + //Если это модальное окно загрузки, то обновляем счетчик + if m.modalType == types.ModalLoading { + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } return m, nil } @@ -106,7 +123,9 @@ func (m *ModalModel) View() string { buttonRow := lipgloss.JoinHorizontal(lipgloss.Center, buttons...) //Тело контента модального окна modalBody := lipgloss.JoinVertical(lipgloss.Center, message, "", buttonRow) - + if m.modalType == types.ModalLoading { + modalBody = lipgloss.JoinVertical(lipgloss.Center, m.spinner.View(), message) + } //В зависимости от типа модального окна разные цвета var borderColor lipgloss.Color var modalTitleStyle lipgloss.Style diff --git a/setup/utils/actions.go b/setup/utils/actions.go new file mode 100644 index 0000000..af5b846 --- /dev/null +++ b/setup/utils/actions.go @@ -0,0 +1,160 @@ +package utils + +import ( + "bubble/types" + "bytes" + "errors" + "fmt" + "github.com/sirupsen/logrus" + "html/template" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// InstallPackages устанавливает требуемые пакеты если их нет в системе +func InstallPackages(requiredPackages []string) error { + for _, requiredPackage := range requiredPackages { + if result, checkError := IsPackageInstall(requiredPackage); !result { + if checkError != nil { + return fmt.Errorf("пакет %s: %v", requiredPackage, checkError) + } + installCmd := exec.Command("apt", "install", "-y", requiredPackage) + out, err := installCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("пакет %s: %v\nOutput: %s", requiredPackage, err, out) + } + } + } + return nil +} + +// IsPackageInstall проверяет установлен ли пакет в системе +func IsPackageInstall(packageName string) (bool, error) { + cmd := exec.Command("dpkg", "-s", packageName) + if err := cmd.Run(); err != nil { + var exitError *exec.ExitError + if errors.As(err, &exitError) && exitError.ExitCode() == 1 { + return false, nil + } + return false, err + } + return true, nil +} + +// ExecuteAction выполняет указанное действие с использованием шаблонов из expr и картой параметров +func ExecuteAction(action *types.SectionAction, application *types.PackageApplication) error { + //Карта параметров + vars := ParamsForCondition(application) + switch action.Type { + case "command": + //Формируем строку команды по шаблону с подставленными значениями и передаем её для выполнения + cmdStr, cmdErr := RenderTemplate(action.Command, vars) + if cmdErr != nil { + return fmt.Errorf("не удалось создать шаблон команды: %v", cmdErr) + } + return RunCommand(cmdStr, action.RunAs) + case "file_template": + //Формируем 2 строки пути, с подставленными значениями и передаем её для выполнения + source, sourceErr := RenderTemplate(action.FileSource, vars) + if sourceErr != nil { + return fmt.Errorf("не удалось создать шаблон файла источника: %v", sourceErr) + } + target, targetErr := RenderTemplate(action.FileTarget, vars) + if targetErr != nil { + return fmt.Errorf("не удалось создать шаблон файла цели: %v", targetErr) + } + return RenderFileTemplate(source, target, vars) + default: + return fmt.Errorf("действие для выполнения '%s' не опознано", action.Type) + } +} + +// RunCommand запускает выполнение переданной команды от указанного лица +func RunCommand(cmdStr, runAs string) error { + if cmdStr == "" { + return nil + } + var cmd *exec.Cmd + if runAs != "" && runAs != "root" { + cmd = exec.Command("su", "-c", cmdStr, runAs) + } else { + cmd = exec.Command("bash", "-c", cmdStr) + } + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("ошибка команды: %v\nВывод: %s", err, string(output)) + } + return nil +} + +// RenderFileTemplate создает целевой файл на основе файла источника с подставленными значениями в нем +func RenderFileTemplate(source, target string, vars map[string]interface{}) error { + if source == "" || target == "" { + return fmt.Errorf("source или target пустые") + } + var data []byte + var err error + //Получаем данные файла источника + if strings.HasPrefix(source, "http") { + resp, err := http.Get(source) + if err != nil { + return fmt.Errorf("ошибка получения данных файла по адресу %s, err : %v", source, err) + } + defer resp.Body.Close() + data, err = io.ReadAll(resp.Body) + } else { + data, err = os.ReadFile(source) + } + if err != nil { + return fmt.Errorf("ошибка чтения данных файла %s, err: %v", source, err) + } + + //Формируем файл с переменными + tmpl, err := template.New("").Parse(string(data)) + if err != nil { + return fmt.Errorf("ошибка формирования шаблона строки: %v", err) + } + var rendered bytes.Buffer + + //В файл помещаем переменные + if err = tmpl.Execute(&rendered, vars); err != nil { + return fmt.Errorf("ошибка подстановки значений в шаблон") + } + if _, err = os.Stat(target); err == nil { + backup := target + ".bak" + time.Now().Format("20060102-150405") + if cpErr := CopyFile(target, backup); cpErr != nil { + logrus.Warnf("Не удалось создать бекап: %v", cpErr) + } else { + logrus.Infof("Бекап %s создан", backup) + } + } + //Создаем папку для целевого файла и записываем в него данные + if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return fmt.Errorf("ошибка создания директории для файла %s, err: %v", target, err) + } + if err = os.WriteFile(target, rendered.Bytes(), 0644); err != nil { + return fmt.Errorf("ошибка записи в файл %s, err: %v", target, err) + } + return nil +} + +// RenderTemplate формирует строку с подставленными параметрами +func RenderTemplate(tmpl string, data map[string]interface{}) (string, error) { + if tmpl == "" { + return "", nil + } + t, err := template.New("").Parse(tmpl) + if err != nil { + return "", fmt.Errorf("ошибка формирования шаблона строки: %v", err) + } + var buf bytes.Buffer + if err = t.Execute(&buf, data); err != nil { + return "", fmt.Errorf("ошибка подстановки значений в шаблон: %v", err) + } + return buf.String(), nil +} diff --git a/setup/utils/field.go b/setup/utils/field.go new file mode 100644 index 0000000..a2a7cef --- /dev/null +++ b/setup/utils/field.go @@ -0,0 +1,221 @@ +package utils + +import ( + "bubble/types" + "fmt" + "github.com/charmbracelet/bubbles/textinput" + "github.com/expr-lang/expr" + "github.com/sirupsen/logrus" + "os/exec" + "reflect" + "regexp" + "strconv" + "strings" +) + +// userRE - с начала строки, делит все по знаку двоеточия, берется первые 3 группы, пример user_name:x:1000: +var userRE = regexp.MustCompile(`^([^:]+):[^:]*:([0-9]+):`) + +// ComputeValue вычисляет значение для поля compute. +func ComputeValue(computeExpr string, app *types.PackageApplication) (interface{}, error) { + if computeExpr == "" { + return nil, nil + } + + env := ParamsForCompute(app) + options := append([]expr.Option{ + expr.Env(env), + expr.AsKind(reflect.String), // по умолчанию строка, если не указано иное + expr.AllowUndefinedVariables(), + }, DefineExprCustomFunctions()...) + + program, err := expr.Compile(computeExpr, options...) + if err != nil { + return nil, fmt.Errorf("ошибка компиляции compute: %v", err) + } + + result, err := expr.Run(program, env) + if err != nil { + return nil, fmt.Errorf("ошибка выполнения compute: %v", err) + } + + return result, nil +} + +// Recompute производит перерасчет всех полей +func Recompute(app *types.PackageApplication) { + for i := range app.Sections { + section := &app.Sections[i] + for j := range section.Fields { + field := §ion.Fields[j] + if field.FieldType == "compute" && field.Compute != "" { + value, err := ComputeValue(field.Compute, app) + if err != nil { + value = fmt.Sprintf("Ошибка вычисления: %v", err) + } + field.Variable.Value = value + } + } + } +} + +// CheckCondition проверяет равно ли переданное условие +func CheckCondition(condition string, app *types.PackageApplication) bool { + if condition == "" { + return true + } + + result, err := expr.Eval(condition, ParamsForCondition(app)) + + if err != nil { + logrus.Errorf("Ошибка в условии '%s': %v", condition, err) + return false + } + + boolResult, ok := result.(bool) + if !ok { + logrus.Warnf("Условие '%s' не вернуло bool, а %T: %v", condition, result, result) + return false + } + + return boolResult +} + +// IsParent проверяет, является ли указанная переменная условием для поля или секции +func IsParent(variableName string, sections []types.Section) bool { + for _, section := range sections { + for _, parent := range section.Parent { + if parent == variableName { + return true + } + } + for _, field := range section.Fields { + for _, parent := range field.Parent { + if parent == variableName { + return true + } + } + + } + } + return false +} + +// IsMultiField проверяет переменную на использование несколькими полями. +func IsMultiField(variableName string, applicationSections []types.Section) bool { + var count int + for _, section := range applicationSections { + for _, field := range section.Fields { + if field.Name == variableName { + count++ + } + } + } + return count > 1 +} + +// SpecialValueHandle получаем необычные значения полей +func SpecialValueHandle(special string) interface{} { + specialValue := strings.Split(special, "code:")[1] + switch specialValue { + case "users": + return GetSystemUsers() + } + return true +} + +// GetSystemUsers получение списка обычных пользователей в системе +func GetSystemUsers() []string { + //результат выполнения команды читающей файл passwd + output, _ := exec.Command("getent", "passwd").Output() + //массив строк разделенных новой строкой + lines := strings.Split(string(output), "\n") + var users []string + for _, line := range lines { + //линия совпадает с регулярным выражением + if matches := userRE.FindStringSubmatch(line); len(matches) == 3 { + //идентификатор больше или равен 1000, uid обычного пользователя в системе + if uid, _ := strconv.Atoi(matches[2]); uid >= 1000 { + users = append(users, matches[1]) + } + } + } + return users +} + +// ValidateCheckField создает функцию типа types.BoolValidateFunc для проверки значения bool полей +func ValidateCheckField(fieldName, variableName string, validations []types.FieldValidate, app *types.PackageApplication) types.BoolValidateFunc { + if len(validations) == 0 { + return nil + } + return func(checked bool) error { + env := ParamsForCompute(app) + env[variableName] = checked + options := append([]expr.Option{ + expr.Env(env), + expr.AllowUndefinedVariables(), + }, DefineExprCustomFunctions()...) + for _, validate := range validations { + program, err := expr.Compile(validate.Rule, options...) + prefix := fmt.Sprintf("Валидация поля %s,", fieldName) + if err != nil { + msg := fmt.Errorf("ошибка компиляции правила: %s, err: %v", validate.Rule, err) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + result, err := expr.Run(program, env) + if err != nil { + msg := fmt.Errorf("ошибка выполнения правила: %s, err: %v", validate.Rule, err) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + if boolValue, ok := result.(bool); ok && !boolValue { + return fmt.Errorf("%s", validate.Message) + } else if !ok { + msg := fmt.Errorf("возвращаемое значение правила: %s не булево", validate.Rule) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + } + return nil + + } +} + +// ValidateField создает функцию типа textinput.ValidateFunc для проверки значения text полей +func ValidateField(fieldName, variableName string, validations []types.FieldValidate, app *types.PackageApplication) textinput.ValidateFunc { + if len(validations) == 0 { + return nil + } + return func(text string) error { + env := ParamsForCompute(app) + env[variableName] = text + options := append([]expr.Option{ + expr.Env(env), + expr.AllowUndefinedVariables(), + }, DefineExprCustomFunctions()...) + for _, validate := range validations { + program, err := expr.Compile(validate.Rule, options...) + prefix := fmt.Sprintf("Валидация поля %s,", fieldName) + if err != nil { + msg := fmt.Errorf("ошибка компиляции правила: %s, err: %v", validate.Rule, err) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + result, err := expr.Run(program, env) + if err != nil { + msg := fmt.Errorf("ошибка выполнения правила: %s, err: %v", validate.Rule, err) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + if boolValue, ok := result.(bool); ok && !boolValue { + return fmt.Errorf("%s", validate.Message) + } else if !ok { + msg := fmt.Errorf("возвращаемое значение правила: %s не булево", validate.Rule) + logrus.Errorf("%s %v", prefix, msg) + return msg + } + } + return nil + } +} diff --git a/setup/utils/utils.go b/setup/utils/utils.go index f83f062..fd5ce5e 100644 --- a/setup/utils/utils.go +++ b/setup/utils/utils.go @@ -1,17 +1,18 @@ package utils import ( - "bytes" + "bubble/types" + "errors" "fmt" - "github.com/sirupsen/logrus" - "html/template" + "github.com/expr-lang/expr" "io" + "io/fs" "net/http" "os" "os/exec" - "path/filepath" + "regexp" + "strconv" "strings" - "time" ) // CalculateSelectOptionsIndex рассчитывает индекс первой и последней опции для отображения в поле выбора @@ -44,91 +45,6 @@ func CalculateSelectOptionsIndex(focus, visibleCount, optionsCount int) (int, in return start, end } -// RenderFileTemplate создает целевой файл на основе файла источника с подставленными значениями в нем -func RenderFileTemplate(source, target string, vars map[string]interface{}) error { - if source == "" || target == "" { - return fmt.Errorf("source или target пустые") - } - var data []byte - var err error - //Получаем данные файла источника - if strings.HasPrefix(source, "http") { - resp, err := http.Get(source) - if err != nil { - return fmt.Errorf("ошибка получения данных файла по адресу %s, err : %v", source, err) - } - defer resp.Body.Close() - data, err = io.ReadAll(resp.Body) - } else { - data, err = os.ReadFile(source) - } - if err != nil { - return fmt.Errorf("ошибка чтения данных файла %s, err: %v", source, err) - } - - //Формируем файл с переменными - tmpl, err := template.New("").Parse(string(data)) - if err != nil { - return fmt.Errorf("ошибка формирования шаблона строки: %v", err) - } - var rendered bytes.Buffer - - //В файл помещаем переменные - if err = tmpl.Execute(&rendered, vars); err != nil { - return fmt.Errorf("ошибка подстановки значений в шаблон") - } - if _, err = os.Stat(target); err == nil { - backup := target + ".bak" + time.Now().Format("20060102-150405") - if cpErr := CopyFile(target, backup); cpErr != nil { - logrus.Warnf("Не удалось создать бекап: %v", cpErr) - } else { - logrus.Infof("Бекап %s создан", backup) - } - } - //Создаем папку для целевого файла и записываем в него данные - if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { - return fmt.Errorf("ошибка создания директории для файла %s, err: %v", target, err) - } - if err = os.WriteFile(target, rendered.Bytes(), 0644); err != nil { - return fmt.Errorf("ошибка записи в файл %s, err: %v", target, err) - } - return nil -} - -// RenderTemplate формирует строку с подставленными параметрами -func RenderTemplate(tmpl string, data map[string]interface{}) (string, error) { - if tmpl == "" { - return "", nil - } - t, err := template.New("").Parse(tmpl) - if err != nil { - return "", fmt.Errorf("ошибка формирования шаблона строки: %v", err) - } - var buf bytes.Buffer - if err = t.Execute(&buf, data); err != nil { - return "", fmt.Errorf("ошибка подстановки значений в шаблон: %v", err) - } - return buf.String(), nil -} - -// RunCommand запускает выполнение переданной команды от указанного лица -func RunCommand(cmdStr, runAs string) error { - if cmdStr == "" { - return nil - } - var cmd *exec.Cmd - if runAs != "" && runAs != "root" { - cmd = exec.Command("su", "-c", cmdStr, runAs) - } else { - cmd = exec.Command("bash", "-c", cmdStr) - } - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("ошибка команды: %v\nВывод: %s", err, string(output)) - } - return nil -} - // CopyFile копирование файла в новое место func CopyFile(src, dst string) error { in, err := os.Open(src) @@ -145,3 +61,247 @@ func CopyFile(src, dst string) error { _, err = io.Copy(out, in) return err } + +// ParamsForCompute получение словаря переменных без приведения типов, для вычисления +func ParamsForCompute(app *types.PackageApplication) map[string]interface{} { + params := make(map[string]interface{}) + + for i := range app.Variables { + v := &app.Variables[i] + value := v.Value + if value == nil { + value = v.Default + } + params[v.Name] = value + } + + // Добавляем значения из активных полей + for _, section := range app.Sections { + for _, field := range section.Fields { + if field.Variable == nil || !field.Active { + continue + } + value := field.Variable.Value + if value == nil { + value = field.Variable.Default + } + params[field.VariableName] = value + } + } + + return params +} + +// ParamsForCondition получение словаря переменных с приведением типов для проверки условий +func ParamsForCondition(app *types.PackageApplication) map[string]interface{} { + params := make(map[string]interface{}) + + for _, variable := range app.Variables { + value := variable.Value + if value == nil { + value = variable.Default + } + + // Приводим к нужному типу, как в твоём старом коде + switch variable.Type { + case "bool": + if s, ok := value.(string); ok { + value = strings.ToLower(s) == "true" + } + case "number", "int", "float": + if s, ok := value.(string); ok { + if n, err := strconv.ParseFloat(s, 64); err == nil { + value = n + } + } + } + + params[variable.Name] = value + } + + // Также добавляем значения из полей секций (на случай, если переменная используется только в поле) + for _, section := range app.Sections { + for _, field := range section.Fields { + if field.Variable == nil || !field.Active { + continue + } + value := field.Variable.Value + if value == nil { + value = field.Variable.Default + } + + switch field.Variable.Type { + case "bool": + if s, ok := value.(string); ok { + value = strings.ToLower(s) == "true" + } + case "number", "int", "float": + if s, ok := value.(string); ok { + if n, err := strconv.ParseFloat(s, 64); err == nil { + value = n + } + } + } + + params[field.VariableName] = value + } + } + + return params +} + +// DefineExprCustomFunctions возвращает собственные функции expr +func DefineExprCustomFunctions() []expr.Option { + return []expr.Option{ + //вычисляет значение по заданному регулярному выражению + expr.Function("regex", + func(args ...interface{}) (interface{}, error) { + if len(args) != 3 { + return "", fmt.Errorf("regex() ожидает 3 аргумента") + } + input := fmt.Sprintf("%v", args[0]) + pattern := fmt.Sprintf("%v", args[1]) + repl := fmt.Sprintf("%v", args[2]) + re, err := regexp.Compile(pattern) + if err != nil { + return input, nil + } + return re.ReplaceAllString(input, repl), nil + }, new(func(string, string, string) string), + ), + //проверяет совпадение переданного значения и указанного регулярного выражения + expr.Function("regex_match", + func(args ...interface{}) (interface{}, error) { + if len(args) != 2 { + return false, fmt.Errorf("regex_match() ожидает 2 аргумента") + } + input := fmt.Sprintf("%v", args[0]) + pattern := fmt.Sprintf("%v", args[1]) + + re, err := regexp.Compile(pattern) + if err != nil { + return false, fmt.Errorf("некоректный regex %s, err:%v", pattern, err) + } + return re.MatchString(input), nil + + }, new(func(string, string) bool), + ), + //возвращает собственный текст ошибки + expr.Function("error", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return nil, fmt.Errorf("error() требует сообщения") + } + return nil, fmt.Errorf("%v", args[0]) + }, + new(func(string) error), + ), + //возвращает тело ответа по указанному адресу + expr.Function("http_get", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return "", nil + } + url := fmt.Sprintf("%v", args[0]) + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body), nil + }, + new(func(string) string), + ), + //возвращает результат выполнения команды из оболочки + expr.Function("shell", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return "", nil + } + cmd := fmt.Sprintf("%v", args[0]) + parts := strings.Fields(cmd) + if len(parts) == 0 { + return "", nil + } + out, err := exec.Command(parts[0], parts[1:]...).CombinedOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil + }, + new(func(string) error), + ), + //возвращает значение указанной переменной из env + expr.Function("env", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return "", nil + } + return os.Getenv(fmt.Sprintf("%v", args[0])), nil + }, new(func(string) error), + ), + //проверяет наличие файла в системе + expr.Function("file_exists", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return false, nil + } + path := fmt.Sprintf("%v", args[0]) + fileInfo, err := os.Stat(path) + if err == nil && !fileInfo.IsDir() { + return true, nil + } else if err == nil && fileInfo.IsDir() { + return false, fmt.Errorf("file is dir") + } + if errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("no such directory or file") + } else { + return false, err + } + }, new(func(string) bool), + ), + //проверяет наличие директории в системе + expr.Function("dir_exists", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return false, nil + } + path := fmt.Sprintf("%v", args[0]) + fileInfo, err := os.Stat(path) + if err == nil && fileInfo.IsDir() { + return true, nil + } else if err == nil && !fileInfo.IsDir() { + return false, fmt.Errorf("file is not a dir") + } + if errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("no such directory or file") + } else { + return false, err + } + }, new(func(string) bool), + ), + //проверяет доступность порта + expr.Function("port_free", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return false, nil + } + port := fmt.Sprintf("%v", args[0]) + cmd := exec.Command("ss", "-ltn", "sport =: "+port) + return cmd.Run() != nil, nil + }, new(func(string) error), + ), + //проверяет наличие пользователя в системе + expr.Function("user_exists", + func(args ...interface{}) (interface{}, error) { + if len(args) == 0 { + return false, nil + } + name := fmt.Sprintf("%v", args[0]) + cmd := exec.Command("id", "-u", name) + return cmd.Run() == nil, nil + }, new(func(string) bool), + ), + } +} diff --git a/types/modal.go b/types/modal.go index 0780c68..a2d03b6 100644 --- a/types/modal.go +++ b/types/modal.go @@ -7,4 +7,5 @@ const ( ModalWarning ModalType = "warning" ModalError ModalType = "error" ModalConfirm ModalType = "confirm" + ModalLoading ModalType = "loading" )