Установщик на Go Wails: Полный гайд. Своя self-hosted двухсерверная VPN-архитектура: ЧАСТЬ 3

от автора

Эта статья является прямым продолжением 2 части статьи про мой проект ServeHub-2. Здесь пойдет речь полностью про разработку установщика на Go Wails v2.

Повествование будет идти таким образом: описание архитектуры в целом, потом код по страницам с картинками как это все выглядит. В отличие от черновика, в этом материале отображен весь рабочий код без сокращений с разбором каждой из страниц.

В Wails основными файлами, с которыми проходит 90% работы это index.html, styles.css, main.js, app.go. Остальные файлы мы либо не трогаем, либо настраиваем один раз в самом начале.

Для фронтенда я использовал стандартный html, css, js без современных фреймворков (так как я в них не разбираюсь). Frontend часть подробно рассматривать в этой статье не планируется, a html будет использоваться только чтобы показать, где используются js функции.

Что вообще делает установщик?

  • Пользователя встречает главная страница с описанием.

  • Проверка запущен/установлен ли Docker (в нем запускается ansible).

  • Копирование кода репозитория рядом с установщиком.

  • Настройка secrets.yml для ansible (форма ввода, данные из которой потом записываются в виде .yml конфига).

  • Проверка конфигурации: показывается как будет выглядеть сохраненный конфиг, также дается возможность сделать себе копию.

  • Выбор деплоя (как себя будет вести Ansible). Делится на 3 варианта: для локального сервера, для удаленного сервера и на оба сервера сразу. И каждый из этих вариантов делится еще на 2: полный деплой (с первоначальной настройкой сервера), либо только разворачивание приложений если проект уже запускался.

  • Подтверждение выбора и начало деплоя. Появляется окно с терминалом в котором пишутся логи ansible.

  • Если деплой завершился ошибкой — кнопка выйти или запустить заново. Если деплой успешен — небольшой гайд что делать дальше и кнопка выхода.

Экскурс в Wails (как работает)

Wails делится на Frontend и Backend часть. Основной его смысл в том, что он отрисовывает страницы как стандартный браузер и переводит выводы Go функций в js. Получается таким образом: в Go мы пишем основную логику, Wails переводит данные из Go к JS, который уже работает со статикой страниц.

Первоначальная настройка

Про установку много рассказывать не буду, для работы понадобятся:

  • Go 1.21+

  • NPM / Node.js (15+)

  • Wails v2

  • libwebkit2gtk-4.1-devтолько для Linux (необходим для работы графического движка Wails)

Чтобы создать первоначальную структуру файлов в терминале нужно выполнить wails init.

Первый файл с которым предстоит работать — main.go (здесь мы создаем наше приложение, даем ему базовые настройки по типу размера окна, привязываем ассеты).

main.go:

package main // Объявление основного пакета приложенияimport ("embed" // Библиотека для внедрения статических файлов в бинарный код приложения"github.com/wailsapp/wails/v2" // Основной пакет фреймворка Wails"github.com/wailsapp/wails/v2/pkg/options" // Конфигурационные параметры приложения"github.com/wailsapp/wails/v2/pkg/options/assetserver" // Настройки сервера статических файлов)// Директива компилятора go:embed:// Указывает компилятору включить содержимое папки 'frontend/dist' в скомпилированный файл.// 'all:' гарантирует, что будут включены скрытые файлы (начинающиеся с точки) // и файлы с нижним подчеркиванием.////go:embed all:frontend/distvar assets embed.FS // Переменная 'assets' представляет собой виртуальную файловую систему, содержащую все упакованные ресурсыfunc main() {// Создаем экземпляр структуры App (содержит логику методов Go, доступных из JS)app := NewApp()// Запуск приложения Wails с заданными параметрами конфигурации:err := wails.Run(&options.App{Title:     "ServeHub-2 Installer", // Заголовок окна приложенияWidth:     1024,                  // Ширина окнаHeight:    768,                   // Высота окнаFrameless: true,                  // Отключение системной рамки для создания кастомного интерфейсаAssetServer: &assetserver.Options{Assets: assets, // Привязываем внедренную файловую систему к серверу ассетов},OnStartup: app.startup, // Метод, вызываемый при запуске приложения для инициализации контекстаBind: []interface{}{ // Регистрация экземпляра App для вызова его методов из JavaScriptapp,},})// Если при запуске произошла критическая ошибка, выводим её в консольif err != nil {println("Error:", err.Error())}}

Логика смены страниц

Изначально Wails — это SPA, т.е все происходит на одной странице, а javascript меняет ее наполнение, поэтому чтобы реализовать приложение с несколькими страницами я использовал механизм “шагов”.

index.html (Контейнер прогресс-бара):

<!-- Контейнер для отображения прогресса установки --><div class="progress-container">    <!--       Область визуализации шагов.       Здесь должны находиться элементы с классом .step-dot,       которые JavaScript будет обновлять через функцию updateStepProgress.    -->    <div class="progress-bar-track">        <div id="step-progress-fill" class="progress-bar-fill"></div>                <!-- Пример точек прогресса, соответствующих 7 шагам -->        <div class="step-dot" data-step="1"></div>        <div class="step-dot" data-step="2"></div>        <div class="step-dot" data-step="3"></div>        <div class="step-dot" data-step="4"></div>        <div class="step-dot" data-step="5"></div>        <div class="step-dot" data-step="6"></div>        <div class="step-dot" data-step="7"></div>    </div>    <!-- Текстовое описание текущего этапа -->    <div class="step-label">        Шаг <span id="step-progress-current">1</span> из 7 —         <span id="step-progress-title">Добро пожаловать</span>    </div></div>

Для управления навигацией написаны соответствующие функции, которые связывают элементы HTML и логику App.js.

main.js (Глобальные функции и роутер):

/** * Словарь названий шагов для отображения в прогресс-баре. */const STEP_TITLES = {    1: 'Добро пожаловать',    2: 'Проверка требований',    3: 'Установка компонентов',    4: 'Настройка secrets.yml',    5: 'Проверка конфигурации',    6: 'Выбор сценария деплоя',    7: 'Запуск деплоя',};/** * Обновляет визуальное состояние прогресс-бара и индикаторов шагов. * @param {number} stepNumber - Текущий номер шага. */function updateStepProgress(stepNumber) {    const totalSteps = 7;    const fill = document.getElementById('step-progress-fill');    const currentEl = document.getElementById('step-progress-current');    const titleEl = document.getElementById('step-progress-title');    // 1. Обновление ширины прогресс-бара (процент заполнения)    if (fill) {        // Вычисляем процент так, чтобы на 1-м шаге было 0%, а на последнем — 100%        const pct = ((stepNumber - 1) / (totalSteps - 1)) * 100;        fill.style.width = `${pct}%`;    }        // 2. Обновление текстовых индикаторов    if (currentEl) currentEl.innerText = stepNumber;    if (titleEl) titleEl.innerText = STEP_TITLES[stepNumber] || '';    // 3. Обновление визуальных точек (dots) прогресса    document.querySelectorAll('.step-dot').forEach((dot) => {        const dotStep = parseInt(dot.getAttribute('data-step'), 10);        dot.classList.remove('completed', 'active');                // Подсвечиваем пройденные шаги как 'completed', текущий — как 'active'        if (dotStep < stepNumber) dot.classList.add('completed');        if (dotStep === stepNumber) dot.classList.add('active');    });}/** * Главный переключатель экранов мастера установки. * Скрывает неактивные шаги и отображает целевой. * @param {number} stepNumber - Номер шага для перехода. */window.goToStep = function(stepNumber) {    // 1. Скрытие всех экранов шагов    document.querySelectorAll('.step').forEach(step => {        step.classList.remove('active');    });    // 2. Отображение целевого экрана    const nextStep = document.getElementById(`step-${stepNumber}`);    if (nextStep) {        nextStep.classList.add('active');    }    // 3. Обновление прогресс-бара    updateStepProgress(stepNumber);    // 4. Триггеры автоматической логики:    // При переходе на определенные шаги запускаем соответствующие скрипты (проверки или установку).    if (stepNumber === 2) {        runDockerCheck();    }    if (stepNumber === 3) {        runInstallation();    }}

Ревью по страницам

Шаг 1: Добро пожаловать (SPA и Роутинг)

На первом экране отображается базовая информация и кнопка запуска. По нажатию вызывается глобальная функция goToStep(2), инициирующая проверку окружения.

HTML-разметка шага (index.html):

<!--   Главный контейнер мастера установки ServeHub-2.  Используется как точка входа для пользователя при первом запуске приложения.--><div class="installer-container">    <header class="installer-header">        <h1>ServeHub-2</h1>        <p class="subtitle">Мастер установки</p>    </header>    <main class="installer-content">        <p>Добро пожаловать! Эта программа поможет вам установить и настроить проект <strong>ServeHub-2</strong> на вашем компьютере.</p>    </main>    <footer class="installer-actions">        <!-- Кнопка перехода к следующему шагу установки -->        <button id="btn-start" class="btn-primary" onclick="goToStep(2)">            Продолжить        </button>                <!-- Ссылка на исходный код проекта для прозрачности и контроля -->        <a href="https://github.com/canntstand/ServeHub-2" target="_blank" class="link-github">            <img src="assets/github-icon.svg" alt="GitHub Logo" class="icon">            GitHub Repository        </a>    </footer></div>

Шаг 2: Проверка требований

Здесь происходит взаимодействие фронтенда с бэкендом для проверки наличия Docker. Для структурирования ответа в Go используется CheckResult.

Бэкенд на Go (app.go):

// CheckResult определяет стандартный формат ответа для всех операций бэкенда.// Поля помечены тегами json для корректной сериализации при передаче данных в JavaScript (через Wails).type CheckResult struct {    Success bool   `json:"success"` // Флаг успешности выполнения операции    Message string `json:"message"` // Описание результата или текст ошибки}// CheckDocker проверяет наличие и работоспособность Docker и Docker Compose в хост-системе.// Метод выполняет попытку вызова команд через os/exec и возвращает статус готовности.func (a *App) CheckDocker() CheckResult {    // 1. Проверка работоспособности Docker:    // Выполняем `docker info` — эта команда возвращает системную информацию, если Docker запущен.    // Если команда завершается с ошибкой, значит демон Docker недоступен или не установлен.    _, err := exec.Command("docker", "info").Output()    if err != nil {        return CheckResult{            Success: false,             Message: "Docker не запущен или не установлен!",        }    }    // 2. Проверка наличия Docker Compose:    // Docker Compose часто устанавливается как плагин или отдельный бинарный файл.    // Вызов `docker compose version` подтверждает, что плагин доступен для использования.    _, err = exec.Command("docker", "compose", "version").Output()    if err != nil {        return CheckResult{            Success: false,             Message: "Docker Compose не найден в системе!",        }    }    // 3. Успешный результат:    // Обе зависимости успешно обнаружены и готовы к работе.    return CheckResult{        Success: true,         Message: "Docker запущен и готов к работе.",    }}

Фронтенд (main.js):

/** * Выполняет проверку доступности Docker и Docker Compose в системе. * Используется на этапе инициализации приложения для подтверждения готовности окружения. */window.runDockerCheck = async function() {    // 1. Получение ссылок на DOM-элементы для обновления состояния интерфейса    const statusText = document.getElementById('status-text');    const spinner = document.getElementById('status-loading');    const btnNext = document.getElementById('btn-step2-next');    const btnRetry = document.getElementById('btn-retry');    // 2. Настройка UI перед запуском проверки:    // Включаем спиннер, блокируем кнопку перехода и сбрасываем состояние текста    spinner.style.display = 'block';    spinner.className = 'spinner';    statusText.innerText = 'Проверяем Docker и Docker Compose...';    statusText.style.color = '#9a9aa0'; // Нейтральный цвет во время ожидания    btnNext.disabled = true;    btnRetry.style.display = 'none';    // 3. Имитация задержки (700 мс):    // Позволяет пользователю заметить визуальную смену состояний, избегая "дерганья" интерфейса    // при очень быстром выполнении проверки на бэкенде.    await new Promise(resolve => setTimeout(resolve, 700));    try {        // 4. Вызов Go-функции CheckDocker через мост Wails:        const result = await CheckDocker();        spinner.style.display = 'none'; // Скрываем индикатор после получения ответа        if (result.success) {            // Если Docker найден и корректно работает            statusText.innerText = `✓ ${result.message}`;            statusText.style.color = '#818cf8'; // Акцентный цвет успеха            btnNext.disabled = false; // Открываем путь к следующему шагу        } else {            // Если возникла логическая ошибка (например, Docker не запущен)            statusText.innerText = `✗ Ошибка: ${result.message}.`;            statusText.style.color = '#e5484d'; // Цвет опасности            btnRetry.style.display = 'block'; // Предлагаем попробовать еще раз        }    } catch (err) {        // 5. Обработка системных исключений (например, сбой соединения с бэкендом)        spinner.style.display = 'none';        statusText.innerText = 'Критическая ошибка при вызове проверки бэкенда:\n' + err;        statusText.style.color = '#e5484d';        btnRetry.style.display = 'block';    }}

Шаг 3: Установка компонентов

На этом шаге скачивается полная архитектура проекта с GitHub и производится поиск уже существующего конфига.

Загрузка архива на бэкенде (app.go):

// InstallProject скачивает исходный код проекта с GitHub и распаковывает его в рабочую директорию.// Метод предотвращает повторную загрузку, если папка проекта уже существует.func (a *App) InstallProject() CheckResult {    // 1. Проверка существования проекта:    // Проверяем наличие папки "ServeHub-2-main". Если она есть, предполагаем,     // что проект уже развернут, и прерываем выполнение, чтобы не перезаписывать данные.    if _, err := os.Stat("ServeHub-2-main"); err == nil {        return CheckResult{            Success: true,             Message: "Проект уже был развернут ранее (папка ServeHub-2-main существует).",        }    }    // 2. Загрузка архива:    // Используем стандартный HTTP-клиент для получения ZIP-архива из репозитория.    url := "https://github.com/canntstand/ServeHub-2/archive/refs/heads/main.zip"    resp, err := http.Get(url)    if err != nil {        return CheckResult{Success: false, Message: "Не удалось подключиться к GitHub: " + err.Error()}    }    defer resp.Body.Close() // Гарантируем закрытие потока ответа    // Проверяем, что сервер вернул статус 200 OK    if resp.StatusCode != http.StatusOK {        return CheckResult{Success: false, Message: "GitHub вернул ошибку: " + resp.Status}    }    // 3. Чтение содержимого в память:    bodyBytes, err := io.ReadAll(resp.Body)    if err != nil {        return CheckResult{Success: false, Message: "Ошибка при чтении данных архива: " + err.Error()}    }    // Инициализируем ZIP-ридер из байтового массива    zipReader, err := zip.NewReader(bytes.NewReader(bodyBytes), int64(len(bodyBytes)))    if err != nil {        return CheckResult{Success: false, Message: "Не удалось прочитать скачанный zip-архив: " + err.Error()}    }    destFolder := "." // Распаковка производится в текущую директорию приложения    // 4. Итерация и распаковка файлов из архива:    for _, f := range zipReader.File {        fpath := filepath.Join(destFolder, f.Name)        // Обработка директорий: создаем папку, если элемент архива является директорией        if f.FileInfo().IsDir() {            os.MkdirAll(fpath, os.ModePerm)            continue        }        // Создаем родительскую структуру папок для файла        if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {            return CheckResult{Success: false, Message: "Не удалось создать структуру папок: " + err.Error()}        }        // Создаем файл на диске с правами доступа, указанными в ZIP-архиве        outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())        if err != nil {            return CheckResult{Success: false, Message: "Не удалось создать файл на диске: " + err.Error()}        }        // Открываем поток файла внутри архива        rc, err := f.Open()        if err != nil {            outFile.Close()            return CheckResult{Success: false, Message: "Ошибка чтения файла из архива: " + err.Error()}        }        // Копируем данные из архива в файл на диске        _, err = io.Copy(outFile, rc)        outFile.Close() // Закрываем файл после записи        rc.Close()      // Закрываем источник данных из архива        if err != nil {            return CheckResult{Success: false, Message: "Ошибка записи файла на диск: " + err.Error()}        }    }    return CheckResult{        Success: true,        Message: "ServeHub-2 успешно скачан и развернут в рабочей папке!",    }}

Умная проверка на фронтенде (main.js):

/** * Инициирует установку проекта (скачивание репозитория) и проверяет наличие  * файла конфигурации для определения дальнейшего пути пользователя. */window.runInstallation = async function() {    // 1. Получение ссылок на DOM-элементы для управления состоянием интерфейса    const installText = document.getElementById('install-text');    const spinner = document.getElementById('install-loading');    const btnFinish = document.getElementById('btn-finish');    const btnRetry = document.getElementById('btn-install-retry');    const btnEditSecrets = document.getElementById('btn-edit-secrets');    const btnStep6Back = document.getElementById('btn-step6-back');    // 2. Настройка начального состояния UI: отображаем индикатор загрузки и блокируем кнопки    spinner.style.display = 'block';    installText.innerText = 'Загрузка архива проекта ServeHub-2 с GitHub в память...';    installText.style.color = 'var(--text-secondary)';        btnFinish.disabled = true;    btnFinish.style.display = 'inline-block';    btnRetry.style.display = 'none';    btnEditSecrets.style.display = 'none';    try {        // 3. Вызов бэкенда для клонирования/скачивания проекта        const result = await InstallProject();                // 4. Обработка успешного скачивания        if (result.success) {            // Проверяем, был ли ранее создан файл secrets.yml            const secretsCheck = await CheckSecrets();            spinner.style.display = 'none';            if (secretsCheck.success) {                // Если конфигурация найдена: пропускаем настройку                installText.innerHTML = `✓ ${result.message}<br><br><span style="color: var(--accent-soft); font-weight: bold;">ℹ Конфигурация найдена:</span> Файл <code style="background: var(--bg-3); padding: 2px 6px; border-radius: 4px;">secrets.yml</code> уже существует в проекте. Шаги создания и настройки будут пропущены.`;                installText.style.color = 'var(--accent-bright)';                                btnFinish.disabled = false;                btnFinish.innerText = 'Перейти к выбору развертки';                btnFinish.setAttribute('onclick', 'goToStep(6)'); // Направляем на выбор сценария                btnEditSecrets.style.display = 'inline-block';                if (btnStep6Back) {                    btnStep6Back.setAttribute('onclick', 'goToStep(3)');                }            } else {                // Если конфигурации нет: направляем пользователя на шаг создания настроек                installText.innerText = `✓ ${result.message}`;                installText.style.color = 'var(--accent-bright)';                                btnFinish.disabled = false;                btnFinish.innerText = 'Продолжить к настройке';                btnFinish.setAttribute('onclick', 'goToStep(4)'); // Направляем на ввод секретов                if (btnStep6Back) {                    btnStep6Back.setAttribute('onclick', 'goToStep(5)');                }            }        } else {            // Ошибка при установке (например, нет интернета)            spinner.style.display = 'none';            installText.innerText = `✗ Ошибка установки: ${result.message}`;            installText.style.color = 'var(--danger)';            btnFinish.style.display = 'none';            btnRetry.style.display = 'inline-block'; // Даем возможность повторить        }    } catch (err) {        // 5. Обработка системных сбоев (ошибки API Wails или падение бэкенда)        spinner.style.display = 'none';        installText.innerText = 'Критическая ошибка в процессе установки:\n' + err;        installText.style.color = 'var(--danger)';        btnFinish.style.display = 'none';        btnRetry.style.display = 'inline-block';    }}

Шаг 4: Настройка secrets.yml

В полной версии представлен весь маппинг полей для парсинга конфигурации из YAML-файла. Парсер корректно собирает многострочные литералы (приватные SSH-ключи), отбрасывая лишние отступы.

Заполнение формы из YAML (main.js):

/** * Словарь сопоставления ключей YAML (из secrets.yml)  * с ID элементов ввода в HTML-форме. * Позволяет легко поддерживать соответствие между структурой конфига и UI. */const SECRETS_FIELD_MAP = {    vps_public_ip: 'sec_vps_ip',    vps_user: 'sec_vps_user',    vps_root_password: 'sec_vps_root_pass',    local_private_ip: 'sec_local_ip',    local_user: 'sec_local_user',    local_root_password: 'sec_local_root_pass',    server_name: 'sec_server_name',    admin_user: 'sec_admin_user',    admin_password: 'sec_admin_pass',    email: 'sec_email',    telegram_token: 'sec_tg_token',    telegram_chat_id: 'sec_tg_chat_id',    webnames_apikey: 'sec_webnames',    postgres_db_nextcloud: 'sec_pg_nc',    postgres_db_vaultwarden: 'sec_pg_vw',    postgres_user: 'sec_pg_user',    postgres_password: 'sec_pg_pass',    nextcloud_redis_pass: 'sec_redis_pass',    secret_vaultwarden_password: 'sec_vw_admin_pass',    ssh_public_key: 'sec_public_ssh_key',    ssh_private_key: 'sec_private_ssh_key',    borgmatic_encryption_passphrase: 'sec_borg_pass',    backup_disk_uuid: 'sec_disk_uuid',};/** * Парсит содержимое YAML-файла и заполняет соответствующие поля HTML-формы. * Поддерживает как простые строковые значения, так и многострочные литералы (для SSH-ключей). * @param {string} yamlText - Исходный текст файла secrets.yml. */function populateSecretsForm(yamlText) {    const lines = yamlText.split('\n');    let i = 0;    while (i < lines.length) {        const line = lines[i];        // 1. Обработка многострочных литералов (ключ вида `key: |`)        // Используется для SSH-ключей, которые могут содержать много строк        const literalMatch = line.match(/^([a-z_]+):\s*\|\s*$/);        if (literalMatch) {            const fieldId = SECRETS_FIELD_MAP[literalMatch[1]];            i++; // Переходим к следующей строке (содержимое блока)            const blockLines = [];                        // Читаем все строки, которые имеют отступ (4 пробела)            while (i < lines.length && (lines[i].startsWith('    ') || lines[i].trim() === '')) {                // Удаляем 4 ведущих пробела, чтобы восстановить оригинальное содержимое                blockLines.push(lines[i].replace(/^ {4}/, ''));                i++;            }                        // Удаляем пустые строки в конце блока            while (blockLines.length && blockLines[blockLines.length - 1] === '') {                blockLines.pop();            }                        // Записываем собранный блок в соответствующее поле формы            if (fieldId) {                const el = document.getElementById(fieldId);                if (el) el.value = blockLines.join('\n');            }            continue; // Пропускаем инкремент i в конце цикла, так как мы уже продвинулись        }        // 2. Обработка стандартных строковых переменных (ключ вида `key: "value"`)        const simpleMatch = line.match(/^([a-z_]+):\s*"(.*)"\s*$/);        if (simpleMatch) {            const fieldId = SECRETS_FIELD_MAP[simpleMatch[1]];            if (fieldId) {                const el = document.getElementById(fieldId);                // Записываем значение из группы регулярного выражения во второе поле ввода                if (el) el.value = simpleMatch[2];            }        }        i++;    }}

Шаг 5: Проверка конфигурации

При нажатии на “Продолжить” мы собираем значения из 23 полей формы и генерируем единый шаблон конфигурации.

Генерация шаблона (main.js):

/** * Функция собирает данные из формы конфигурации, формирует YAML-файл в виде строки * и подготавливает его для предварительного просмотра пользователем. */window.generateAndReviewYaml = function() {    // 1. Сбор данных из всех полей ввода формы по их ID    const vpsIp = document.getElementById('sec_vps_ip').value;    const vpsUser = document.getElementById('sec_vps_user').value;    const vpsRootPass = document.getElementById('sec_vps_root_pass').value;    const localIp = document.getElementById('sec_local_ip').value;    const localUser = document.getElementById('sec_local_user').value;    const localRootPass = document.getElementById('sec_local_root_pass').value;        const serverName = document.getElementById('sec_server_name').value;    const adminUser = document.getElementById('sec_admin_user').value;    const adminPass = document.getElementById('sec_admin_pass').value;    const email = document.getElementById('sec_email').value;        const tgToken = document.getElementById('sec_tg_token').value;    const tgChatId = document.getElementById('sec_tg_chat_id').value;    const webnamesKey = document.getElementById('sec_webnames').value;        const pgNc = document.getElementById('sec_pg_nc').value;    const pgVw = document.getElementById('sec_pg_vw').value;    const pgUser = document.getElementById('sec_pg_user').value;    const pgPass = document.getElementById('sec_pg_pass').value;    const redisPass = document.getElementById('sec_redis_pass').value;        const vwAdminPass = document.getElementById('sec_vw_admin_pass').value;    const publicsshKey = document.getElementById('sec_public_ssh_key').value;    const privatesshKey = document.getElementById('sec_private_ssh_key').value;    const borgPass = document.getElementById('sec_borg_pass').value;    const diskUuid = document.getElementById('sec_disk_uuid').value;    // 2. Форматирование приватного SSH ключа:    // YAML требует специфического отступа для многострочных литералов (символ `|`).    // Мы разбиваем строку по символу переноса и добавляем 4 пробела к каждой строке.    const formattedPrivateKey = privatesshKey        .split('\n')        .map(line => '    ' + line)        .join('\n');    // 3. Формирование YAML-документа:    // Используем шаблонные строки (` `) для вставки переменных.     // Это обеспечивает правильную структуру конфига, понятную для Ansible.    generatedYamlString = `# ==============================================================================#                 ServeHub-2: СГЕНЕРИРОВАННЫЙ КОНФИГУРАЦИОННЫЙ SECRETS.YML# ==============================================================================# 1. СЕТЕВАЯ ИНФРАСТРУКТУРА И ПОЛЬЗОВАТЕЛИvps_public_ip: "${vpsIp}"vps_user: "${vpsUser}"vps_root_password: "${vpsRootPass}"local_private_ip: "${localIp}"local_user: "${localUser}"local_root_password: "${localRootPass}"# 2. ГЛОБАЛЬНЫЕ НАСТРОЙКИserver_name: "${serverName}"admin_user: "${adminUser}"admin_password: "${adminPass}"email: "${email}"# 3. НАСТРОЙКИ АЛЕРТИНГАtelegram_token: "${tgToken}"telegram_chat_id: "${tgChatId}"# 4. ИНТЕГРАЦИЯ DNSwebnames_apikey: "${webnamesKey}"# 5. НАСТРОЙКИ POSTGRESQLpostgres_db_nextcloud: "${pgNc}"postgres_db_vaultwarden: "${pgVw}"postgres_user: "${pgUser}"postgres_password: "${pgPass}"nextcloud_redis_pass: "${redisPass}"# 6. СЕКРЕТЫ СЕРВИСОВsecret_vaultwarden_password: "${vwAdminPass}"ssh_public_key: "${publicsshKey}"ssh_private_key: |${formattedPrivateKey}borgmatic_encryption_passphrase: "${borgPass}"backup_disk_uuid: "${diskUuid}"`;    // 4. Отображение результата:    // Выводим полученный YAML в блок предпросмотра на странице    document.getElementById('yaml-preview').innerText = generatedYamlString;    // 5. Управление навигацией:    // Настраиваем кнопку "Назад", чтобы она вела на экран редактирования данных    const btnStep6Back = document.getElementById('btn-step6-back');    if (btnStep6Back) {        btnStep6Back.setAttribute('onclick', 'goToStep(5)');    }        // Переход к шагу 5 (просмотр и подтверждение)    goToStep(5);}

Сохранение файла (app.go):

// SaveSecrets сохраняет сгенерированный YAML-контент в файл конфигурации проекта.// Метод принимает строку с содержимым будущего secrets.yml и возвращает результат операции.func (a *App) SaveSecrets(yamlContent string) CheckResult {    // 1. Определение пути к файлу:    // Формируем относительный путь к директории, где Ansible ожидает увидеть файл переменных.    // Используется filepath.Join для кроссплатформенной совместимости путей.    targetDir := filepath.Join(".", "ServeHub-2-main", "ansible", "vars")    // 2. Подготовка директорий:    // os.MkdirAll создает всю цепочку директорий, если они отсутствуют.    // os.ModePerm (0777) устанавливает полные права доступа для создаваемых папок.    err := os.MkdirAll(targetDir, os.ModePerm)    if err != nil {        // Возвращаем ошибку, если у приложения нет прав на создание папок или возникла проблема с диском.        return CheckResult{            Success: false,             Message: "Не удалось создать директорию конфигурации: " + err.Error(),        }    }    // 3. Формирование пути к файлу:    targetFile := filepath.Join(targetDir, "secrets.yml")    // 4. Запись данных:    // os.WriteFile создает файл (или перезаписывает существующий) и записывает в него байты.    // Права 0644 (rw-r--r--) позволяют владельцу читать и писать в файл, а остальным — только читать.    // Это стандартный уровень безопасности для конфигурационных файлов с секретами.    err = os.WriteFile(targetFile, []byte(yamlContent), 0644)    if err != nil {        // Если запись не удалась (например, файл занят или доступ ограничен), возвращаем ошибку.        return CheckResult{            Success: false,             Message: "Не удалось записать файл secrets.yml: " + err.Error(),        }    }    // 5. Успешное завершение:    // Конфигурация успешно сохранена на диск.    return CheckResult{        Success: true,         Message: "Файл secrets.yml успешно создан!",    }}

Шаг 6: Выбор сценария деплоя

Здесь реализована логика выделения выбранной карточки развертывания и подтверждения старта.

Выбор карточки (main.js):

// Глобальная переменная для хранения выбранного пользователем ID сценария развертывания.// Инициализируется нулем, так как выбор еще не сделан.let selectedOptionNum = 0; /** * Обрабатывает выбор карточки сценария деплоя пользователем. * @param {number} num - Идентификатор выбранного сценария. */window.selectDeployOption = function(num) {    selectedOptionNum = num;    // 1. Сброс визуального состояния:    // Находим все карточки с классом 'deploy-option-card' и убираем у них класс 'selected',    // чтобы при выборе новой карточки старая потеряла выделение.    document.querySelectorAll('.deploy-option-card').forEach(card => {        card.classList.remove('selected');    });    // 2. Установка нового выделения:    // Находим карточку по ID (например, 'opt-1') и добавляем ей класс 'selected' для подсветки через CSS.    const selectedCard = document.getElementById(`opt-${num}`);    if (selectedCard) {        selectedCard.classList.add('selected');    }    // 3. Активация кнопки перехода:    // Делаем кнопку 'Далее' доступной, так как сценарий теперь выбран.    const btnNext = document.getElementById('btn-deploy-next');    if (btnNext) {        btnNext.disabled = false;    }}/** * Подготавливает данные для финального экрана перед началом выполнения Ansible-сценария. */window.confirmDeployment = function() {    // Если пользователь не выбрал сценарий (выбран 0), прерываем выполнение.    if (selectedOptionNum === 0) return;    // Словарь текстовых описаний для понятного отображения выбранного пункта в UI.    const optionTexts = {        1: "Пункт 1. Полный деплой (Локальный + VPS)",        2: "Пункт 2. Запуск сервисов (Локальный + VPS)",        3: "Пункт 3. Полный деплой (Локальный)",        4: "Пункт 4. Запуск сервисов (Локальный)",        5: "Пункт 5. Полный деплой (VPS)",        6: "Пункт 6. Запуск сервисов (VPS)"    };    // Обновляем текстовое поле на экране подтверждения, подставляя описание выбранного сценария.    const confirmTextEl = document.getElementById('confirm-option-text');    if (confirmTextEl) {        confirmTextEl.innerText = optionTexts[selectedOptionNum] || `Сценарий №${selectedOptionNum}`;    }    // 4. Проверка состояния отладки (логирование):    // Проверяем, включен ли переключатель "Детализация логов" (-vvv).    const isVerbose = document.getElementById('verbose-logs-toggle').checked;    const confirmVerboseEl = document.getElementById('confirm-verbose-text');        // Выводим статус отладки на экран подтверждения для информирования пользователя.    if (confirmVerboseEl) {        confirmVerboseEl.innerText = isVerbose ? "Да (-vvv)" : "Нет";    }    // Переход к шагу 7 — непосредственно экрану запуска деплоя.    goToStep(7);}

Шаг 7: Запуск деплоя

Это самый сложный шаг, где происходит запуск процесса в псевдотерминале и трансляция логов в реальном времени. В полных функциях видно, как обрабатываются все 6 вариантов деплоя через switch/case, а также как удаляются цветовые коды из потока логов.

Бэкенд: Запуск Ansible в PTY (app.go):

// ansiEscape — это регулярное выражение для поиска и удаления ANSI escape-последовательностей.// Эти коды используются терминалом для раскрашивания текста (цвета шрифта, фона, жирность).// Поскольку мы выводим логи в HTML-блок <pre>, нам нужно их очистить, чтобы в логах не появлялись "мусорные" символы.var ansiEscape = regexp.MustCompile("\x1b\\][^\x07\x1b]*(\x07|\x1b\\\\)" + // Коды управления операционной системой"|\x1b\\[[0-9;?]*[a-zA-Z]" + // Коды форматирования графики (цвета и стили)"|\x1b[()[A-Za-z0-9]" + // Коды выбора кодировки и наборов символов"|\x1b[=>78]", // Коды инициализации/сброса терминала)// stripAnsi удаляет все найденные ANSI-коды из строки.func stripAnsi(s string) string {return ansiEscape.ReplaceAllString(s, "")}// RunDeployment выполняет Ansible-плэйбуки через Docker-контейнер, // используя PTY для эмуляции интерактивного терминала.func (a *App) RunDeployment(option int, verbose bool) CheckResult {var steps []stringdebugArgs := ""if verbose {debugArgs = " -vvv" // Добавляем флаг подробного логирования, если нужно}// 1. Формирование сценария деплоя на основе выбора пользователя// Каждый шаг — это отдельный запуск команды ansible-playbook через docker composeswitch option {case 1:steps = []string{fmt.Sprintf("ansible-playbook -i ansible/inventory.ini ansible/deploy.yml --limit vps,local --tags bootstrap%s", debugArgs),fmt.Sprintf("ansible-playbook -i ansible/inventory.ini ansible/deploy.yml --limit vps --skip-tags bootstrap%s", debugArgs),fmt.Sprintf("ansible-playbook -i ansible/inventory.ini ansible/deploy.yml --limit local --skip-tags bootstrap%s", debugArgs),}// ... (другие кейсы)default:return CheckResult{Success: false, Message: "Неизвестный сценарий развертывания!"}}// 2. Последовательное выполнение команд из списка stepsfor _, ansibleCmd := range steps {wailsRuntime.EventsEmit(a.ctx, "deploy-log")// Команда для исполнения внутри контейнераcmdArgs := []string{"compose","-f", "./ServeHub-2-main/docker-compose.ansible.yaml","run", "--rm", "ansible","sh", "-c", ansibleCmd,}// 3. Создание псевдотерминала (PTY):// Ansible требует PTY, чтобы корректно отображать прогресс-бары и цветовую разметку.ptty, err := pty.New()if err != nil {return CheckResult{Success: false, Message: fmt.Sprintf("Не удалось создать псевдотерминал: %s", err.Error())}}_ = ptty.Resize(200, 50) // Устанавливаем размер окна терминала для корректного переноса строк// Инициализация процессаcmd := ptty.CommandContext(a.ctx, "docker", cmdArgs...)cmd.Env = append(os.Environ(), "TERM=xterm-256color") // Эмулируем полноценный терминалif err := cmd.Start(); err != nil {ptty.Close()return CheckResult{Success: false, Message: fmt.Sprintf("Не удалось запустить Docker: %s", err.Error())}}// Сохраняем ссылки в структуре App, чтобы функция SendEnter могла ими воспользоватьсяa.ptty = pttya.cmdStdin = pttya.activeCmd = cmd// 4. Асинхронное чтение логов:// Создаем отдельную горутину, которая постоянно читает вывод из PTYgo func(p pty.Pty) {scanner := bufio.NewScanner(p)scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // Увеличиваем буфер для длинных строк логаfor scanner.Scan() {clean := stripAnsi(scanner.Text())if clean == "" {continue}// Отправляем строку на фронтенд (Wails событие)wailsRuntime.EventsEmit(a.ctx, "deploy-log", clean+"\n")}}(ptty)// Ждем завершения текущего плэйбукаwaitErr := cmd.Wait()// Очистка ресурсов после завершения командыptty.Close()a.ptty = nila.cmdStdin = nilif waitErr != nil {return CheckResult{Success: false,Message: fmt.Sprintf("Ansible завершился с ошибкой: %s", waitErr.Error()),}}}return CheckResult{Success: true,Message: "Все этапы деплоя Ansible успешно выполнены!",}}

Фронтенд: Прослушивание логов (main.js):

/** * Основная функция для инициализации и запуска процесса деплоя через Ansible. * Управляет состоянием интерфейса, подпиской на потоки логов и обработкой ошибок. */window.startDeployment = async function() {    // 1. Получение ссылок на DOM-элементы для управления интерфейсом    const logContainer = document.getElementById('deploy-logs-container');    const logPre = document.getElementById('deploy-logs');    const btnRun = document.getElementById('btn-deploy-run');    const btnBack = document.getElementById('btn-deploy-back');    const btnExit = document.getElementById('btn-deploy-exit');    const btnEnter = document.getElementById('btn-deploy-enter');    const isVerbose = document.getElementById('verbose-logs-toggle').checked;    // 2. Первичная настройка UI: отображение контейнера логов и статусная строка    logContainer.style.display = 'block';    logPre.innerText = 'Подготовка окружения и инициализация процесса деплоя...\n';    // Блокировка кнопок навигации, чтобы пользователь не мог прервать процесс    btnRun.disabled = true;    btnBack.disabled = true;    if (btnExit) btnExit.style.display = 'none';    // Показываем кнопку "Enter" для интерактивного взаимодействия с Ansible    if (btnEnter) btnEnter.style.display = 'block';    const postDeployGuide = document.getElementById('post-deploy-guide');    if (postDeployGuide) postDeployGuide.style.display = 'none';    /**     * Вспомогательная функция для добавления логов в терминал с автоскроллом.     */    function addLog(message) {        const logPre = document.getElementById('deploy-logs');        // Проверяем, находится ли скролл в самом низу (погрешность 5px)        const isAtBottom = (logPre.scrollHeight - logPre.clientHeight) <= (logPre.scrollTop + 5);                // Добавляем текст в блок логов        logPre.innerText += message;                // Если пользователь не прокручивал лог вручную вверх, автоматически прокручиваем вниз        if (isAtBottom) {            logPre.scrollTop = logPre.scrollHeight;        }    }    // 3. Подписка на событие логов:    // Мы используем EventsOn из Wails, чтобы слушать события, приходящие из Go.    // Флаг deployLogListened предотвращает многократную подписку при повторных запусках.    if (!window.deployLogListened) {        EventsOn('deploy-log', (message) => {            addLog(message);        });        window.deployLogListened = true;    }    // 4. Запуск основного процесса деплоя:    try {        // Вызов функции Go-бэкенда. Выполнение блокирует этот поток, пока процесс не завершится.        const result = await RunDeployment(selectedOptionNum, isVerbose);                // Скрываем кнопку ввода, так как деплой завершился        if (btnEnter) btnEnter.style.display = 'none';        // 5. Обработка результата (Успех/Ошибка Ansible):        if (result.success) {            logPre.innerText += `\n[ УСПЕХ ]: ${result.message}\n`;            logPre.style.borderColor = '#818cf8'; // Подсветка границ в случае успеха            btnRun.style.display = 'none';            btnBack.style.display = 'none';            if (btnExit) {                btnExit.style.display = 'block';                btnExit.className = 'btn-primary';            }            // Показываем руководство, что делать после успешного деплоя            const guide = document.getElementById('post-deploy-guide');            if (guide) guide.style.display = 'block';        } else {            // Обработка логических ошибок Ansible (например, ошибка конфигурации)            logPre.innerText += `\n[ ОШИБКА ]: Деплой завершился неудачей.\nПричина: ${result.message}\n`;            logPre.style.borderColor = '#e5484d'; // Подсветка границ (красный)            btnRun.disabled = false;            btnBack.disabled = false;            if (btnExit) btnExit.style.display = 'block';        }    } catch (err) {        // 6. Обработка критических системных ошибок (например, падение самого Docker/Ansible)        if (btnEnter) btnEnter.style.display = 'none';        logPre.innerText += `\n[ КРИТИЧЕСКАЯ ОШИБКА СИСТЕМЫ ]: Ошибка вызова бэкенда:\n${err}\n`;        logPre.style.borderColor = '#e5484d';        btnRun.disabled = false;        btnBack.disabled = false;        if (btnExit) btnExit.style.display = 'block';    }}

Отправка сигнала Enter в процесс (app.go):

// SendEnter отправляет сигнал нажатия клавиши "Enter" в интерактивный процесс Ansible.// Данный метод используется, когда Ansible приостанавливает выполнение для ожидания// пользовательского ввода (например, подтверждение sudo-пароля или перезаписи файла).func (a *App) SendEnter() CheckResult {    // 1. Проверка состояния процесса:    // Мы проверяем, инициализирован ли интерфейс записи в стандартный поток ввода (stdin).    // Если cmdStdin равен nil, значит процесс либо еще не запущен, либо уже завершился,    // и отправка сигнала невозможна.    if a.cmdStdin == nil {        return CheckResult{            Success: false,             Message: "Процесс деплоя не запущен или не ожидает ввода",        }    }    // 2. Отправка управляющей последовательности:    // Мы записываем в поток ввода "\r\n" (Return + Newline).     // Это стандартный способ эмуляции нажатия клавиши Enter для POSIX-совместимых терминалов,    // через которые Ansible взаимодействует с пользователем.    _, err := a.cmdStdin.Write([]byte("\r\n"))        // 3. Обработка ошибок записи:    // Если возникла ошибка записи в поток (например, канал был закрыт до того, как    // мы успели отправить байты), возвращаем статус ошибки вместе с описанием причины.    if err != nil {        return CheckResult{            Success: false,             Message: fmt.Sprintf("Не удалось отправить Enter: %s", err.Error()),        }    }    // 4. Подтверждение операции:    // Если запись прошла успешно, возвращаем статус успеха.    return CheckResult{        Success: true,         Message: "Сигнал Enter успешно отправлен",    }}

Для фронтенда также реализована функция вызова бэкенда:

/** * Функция-обертка для отправки сигнала Enter в запущенный процесс Ansible. * Используется в интерфейсе для взаимодействия с интерактивными запросами Ansible * (например, подтверждение пароля или перезаписи файлов), если они возникают. */window.sendEnterToAnsible = async function() {    try {        // Вызываем GO-функцию SendEnter, которая реализована в app.go        // Она обращается напрямую к стандартному вводу (stdin) запущенного псевдотерминала (PTY)        const result = await SendEnter();                // Проверяем результат выполнения операции на бэкенде        if (!result.success) {            // Если возникла логическая ошибка (например, процесс уже завершился),             // выводим предупреждение в консоль разработчика, не прерывая работу приложения            console.warn("Попытка отправки Enter была отклонена:", result.message);        } else {            // Опционально: можно добавить лог для отладки успешного действия            console.log("Сигнал Enter успешно отправлен в процесс.");        }    } catch (err) {        // Обработка критических ошибок (например, потеря связи с бэкендом Wails)        // Выводим детальную информацию об ошибке в консоль        console.error("Произошла системная ошибка при попытке отправки Enter:", err);    }}

Заключение

Постарался подробно пройти по основным моментам установщика Go Wails. Некоторые моменты пришлось опустить, однако в целом полная архитектура и работа наглядно видны, надеюсь что это так.

Вероятно это последняя статья по проекту ServeHub-2, я еще конечно могу рассказать про сборку AppImage, более подробно пройтись по функциям и тд, но вероятно я это сделаю если будет еще что-то, что можно добавить для полноценной статьи.

Внешний вид установщика можно посмотреть, скачав бинарник или AppImage из релизов проекта, я честно пытался встроить нормальное видео, но у меня не очень получилось, поэтому вот так.

Вся кодовая база проекта, подробная документация, инструкции по развертыванию открыты и доступны для сообщества:

🔗 GitHub-репозиторий: https://github.com/canntstand/ServeHub-2

Буду рад вашему фидбеку в комментариях!

ссылка на оригинал статьи https://habr.com/ru/articles/1060336/