package lexicomai import ( "context" "errors" "fmt" "path/filepath" "strings" "time" ) // baseSetupTimeout — таймаут на "тяжёлое" создание базы /opt/pve (apt + uv + // загрузка python + venv). Первичная установка базы может занять минуты, поэтому // такой шаг не режется обычным (15с) таймаутом инструмента. const baseSetupTimeout = 20 * time.Minute // baseSetupScript — команды подготовки минимальной базы /opt/pve. // Идемпотентно: создаёт venv на python3.12, ставит cythonize/gunicorn/uvicorn // и пишет .version. uv и build-essential доставляет сама, чтобы инсталлятор // продуктов («install_pve») не зависел от предустановленного uv. // // Шаги, обязательные для корректной базы, не глушатся: set -e останавливает // скрипт при первой же ошибке, и она доходит до инструмента (иначе «битая» // база молча выдавалась бы за успешную). apt-get update допускает сбой // (сеть может быть недоступна на момент шага) — это не фатально. var baseSetupScript = ` set -e export DEBIAN_FRONTEND=noninteractive # Заголовки ядра и python-dev ставит сам инсталлятор продукта (install_pve), # поэтому в базе они не обязательны и не запрашиваем (имя пакета # linux-headers-$(uname -r) не всегда существует — из-за него база падала). apt-get update -y || true apt-get install -y curl if ! command -v uv >/dev/null 2>&1; then curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh fi uv python install 3.12.12 uv venv /opt/pve --python 3.12.12 uv pip install --python /opt/pve/bin/python cython gunicorn uvicorn echo "7.006 release" > /opt/pve/.version echo "pve base ready" ` // ensureBase создаёт минимальную базу /opt/pve, если её нет (идемпотентно). // Возвращает текст статуса для пользователя. func (m *Manager) ensureBase(ctx context.Context, target *Target) (string, error) { exists, err := m.runSudo(ctx, target, "test -x /opt/pve/bin/python && test -f /opt/pve/.version && echo yes || echo no") if err != nil { return "", err } if strings.TrimSpace(exists) == "yes" { return "pve base already present", nil } longCtx, cancel := context.WithTimeout(ctx, baseSetupTimeout) defer cancel() res, err := m.ExecWithInput(longCtx, target, "sudo -n bash -s", strings.NewReader(baseSetupScript)) if err != nil { return "", fmt.Errorf("base setup: %w", err) } return strings.TrimSpace(res.Output), nil } // BaseEnsure гарантирует минимальную базу /opt/pve на машине (публичный // инструмент для администратора). Идемпотентна. Проходит гейт мутаций. func (m *Manager) BaseEnsure(ctx context.Context, alias string) (string, error) { _, target, err := m.mutableTarget(alias) if err != nil { return "", err } return m.ensureBase(ctx, target) } // Install запускает установку продукта на машине (асинхронно) и возвращает // id инстал-сессии для опроса статуса. Может быть использовано и как // «обновление» (с пустой версией берётся последняя в ветке). Мутация. func (m *Manager) Install(ctx context.Context, alias, product, version, branch string) (string, error) { _, target, err := m.mutableTarget(alias) if err != nil { return "", err } // Сначала ищем дистрибутив, уже лежащий в /opt/distr на машине (офлайн/ // стейджинг, без повторного скачивания больших файлов). Если его нет — // резолвим и качаем из репозитория. name, err := m.distroName(ctx, target, product, version, branch) if err != nil { return "", err } if _, err := m.ensureBase(ctx, target); err != nil { return "", fmt.Errorf("base ensure: %w", err) } if !m.distroStaged(ctx, target, name) { if err := m.pushDistro(ctx, target, name); err != nil { return "", err } } // Пути сгенерированы модулем (без пробелов/спецсимволов), поэтому их можно // подставлять внутрь одинарных кавычек bash -c без повторного квотирования // (shellQuote дал бы вложенные кавычки и сломал бы команду). distPath := "/opt/distr/" + name logFile := "/tmp/inst_" + name + ".log" cmd := fmt.Sprintf("sudo -n bash -c 'nohup bash %s --install > %s 2>&1 & echo $!'", distPath, logFile) if _, err := m.ExecOnTarget(ctx, target, cmd); err != nil { return "", fmt.Errorf("launch install: %w", err) } sess := m.sessions.add(target.Alias, product, branch, version, name, logFile) return sess.ID, nil } // pushDistro стримит дистрибутив из репозитория на машину в /opt/distr/ // (через sudo tee, без буферизации файла целиком). func (m *Manager) pushDistro(ctx context.Context, target *Target, name string) error { // Ветка и продукт нужны для пути в репозитории; имя файла уже известно. // Достаём продукт/ветку из имени, чтобы открыть правильный поток. parts, err := parseDistroName(name) if err != nil { return err } if _, err := m.runSudo(ctx, target, "mkdir -p /opt/distr"); err != nil { return err } stream, err := m.RepoStream(ctx, parts.Product, parts.Branch, name) if err != nil { return err } defer stream.Close() cmd := "sudo -n tee " + shellQuote("/opt/distr/"+name) + " >/dev/null && sudo -n chmod +x " + shellQuote("/opt/distr/"+name) if _, err := m.ExecWithInput(ctx, target, cmd, stream); err != nil { return fmt.Errorf("push distro: %w", err) } return nil } // distroName возвращает имя дистрибутива. Приоритет — уже загруженные файлы в // /opt/distr (офлайн/стейджинг); если подходящего нет — обращение к репозиторию. func (m *Manager) distroName(ctx context.Context, target *Target, product, version, branch string) (string, error) { branch, err := normalizeBranch(branch) if err != nil { return "", err } out, err := m.runSudo(ctx, target, "ls -1 /opt/distr/"+shellQuote(product)+"-*.sh 2>/dev/null") if err != nil { // Каталога/файлов нет — ls вернул не-ноль; это не фатально, идём в репо. out = "" } var names []string for _, n := range strings.Split(out, "\n") { if n = strings.TrimSpace(n); n != "" { names = append(names, filepath.Base(n)) } } if len(names) > 0 { if name, err := ResolveDistro(names, product, version, branch); err == nil { return name, nil } } return m.RepoResolve(ctx, product, version, branch) } // distroStaged сообщает, лежит ли уже дистрибутив на машине (не скачиваем повторно). func (m *Manager) distroStaged(ctx context.Context, target *Target, name string) bool { out, err := m.runSudo(ctx, target, "test -f /opt/distr/"+shellQuote(name)+" && echo yes || echo no") return err == nil && strings.TrimSpace(out) == "yes" } // InstallStatus возвращает прогресс/результат установки по сессии. func (m *Manager) InstallStatus(ctx context.Context, target *Target, sess *InstallSession) (string, error) { logExists, err := m.runSudo(ctx, target, "test -f "+shellQuote(sess.LogFile)+" && echo yes || echo no") if err != nil { return "", err } if strings.TrimSpace(logExists) != "yes" { return fmt.Sprintf("install %s/%s: started (no log yet)", sess.Product, sess.Branch), nil } tail, err := m.runSudo(ctx, target, "tail -n 40 "+shellQuote(sess.LogFile)) if err != nil { return "", err } active := "inactive" if out, err := m.runSudo(ctx, target, "systemctl is-active "+shellQuote(ServiceUnit(sess.Product))); err == nil { active = strings.TrimSpace(out) } state := "running" switch { case strings.Contains(tail, "Успех"): state = "success" case strings.Contains(tail, "Ошибка"): state = "error" case active == "active" || active == "failed": state = "finished (unit " + active + ")" } // Хвост лога без маркеров "move ..." — конец инсталлятора обычно содержит // полезный итог (порт/логин/пароль). return fmt.Sprintf("install %s/%s: %s (unit=%s)\n%s", sess.Product, sess.Branch, state, active, lastMeaningfulLine(tail)), nil } // Uninstall удаляет продукт с машины через инсталлятор --uninstall. Мутация. // Инсталлятор спрашивает подтверждение — скормить 'y' интерактивному промпту, // иначе без tty он отменяет удаление. func (m *Manager) Uninstall(ctx context.Context, alias, product string) (string, error) { _, target, err := m.mutableTarget(alias) if err != nil { return "", err } // Инсталлятор может быть не на месте (поставлен ранее) — берём из distr. script := fmt.Sprintf(` f=$(ls /opt/distr/%s-*.sh 2>/dev/null | head -1) if [ -z "$f" ]; then echo "NO_INSTALLER"; exit 0; fi printf 'y\n' | sudo -n bash "$f" --uninstall || echo "UNINSTALL_FAILED" echo "UNINSTALL_DONE" `, product) out, err := m.ExecWithInput(ctx, target, "sudo -n bash -s", strings.NewReader(script)) if err != nil { return "", err } switch c := out.Combined; { case strings.Contains(c, "NO_INSTALLER"): return "", errors.New("no installer for uninstall (distro not present)") case strings.Contains(c, "UNINSTALL_FAILED"): return "", errors.New("uninstall command failed") default: return "uninstall complete", nil } } // lastMeaningfulLine ищет последнюю непустую строку, не начинающуюся с «move». func lastMeaningfulLine(out string) string { lines := strings.Split(out, "\n") for i := len(lines) - 1; i >= 0; i-- { l := strings.TrimSpace(lines[i]) if l != "" && !strings.HasPrefix(l, "move ") && !strings.HasPrefix(l, ">") { return l } } return "" }