247 lines
11 KiB
Go
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 ""
|
|
}
|