Files
forge-tools-lexicomai/internal/lexicomai/install.go
T
2026-10-01 09:50:59 +07:00

247 lines
11 KiB
Go

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 ""
}