133 lines
5 KiB
Go
133 lines
5 KiB
Go
package external
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
|
||
"github.com/vladimir/goherence/internal/ssh"
|
||
)
|
||
|
||
// detectRemoteArch определяет "goos/goarch" удалённого хоста через uname,
|
||
// чтобы выбрать нужный бинарник модуля из BinPath.
|
||
func detectRemoteArch(conn *ssh.Conn) (string, error) {
|
||
res, err := conn.Run("uname -s; uname -m")
|
||
if err != nil {
|
||
return "", fmt.Errorf("detectRemoteArch: %w", err)
|
||
}
|
||
lines := strings.Split(strings.TrimSpace(res.Stdout), "\n")
|
||
if len(lines) < 2 {
|
||
return "", fmt.Errorf("detectRemoteArch: неожиданный вывод uname: %q", res.Stdout)
|
||
}
|
||
goos := normalizeOS(lines[0])
|
||
goarch := normalizeArch(lines[1])
|
||
return goos + "/" + goarch, nil
|
||
}
|
||
|
||
func normalizeOS(uname string) string {
|
||
switch strings.TrimSpace(uname) {
|
||
case "Linux":
|
||
return "linux"
|
||
case "Darwin":
|
||
return "darwin"
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(uname))
|
||
}
|
||
}
|
||
|
||
func normalizeArch(uname string) string {
|
||
switch strings.TrimSpace(uname) {
|
||
case "x86_64", "amd64":
|
||
return "amd64"
|
||
case "aarch64", "arm64":
|
||
return "arm64"
|
||
default:
|
||
return strings.TrimSpace(uname)
|
||
}
|
||
}
|
||
|
||
// resolveBaseDir определяет директорию на хосте для доставленных модулей —
|
||
// под домашней директорией подключившегося пользователя, а не под /opt,
|
||
// который требует root. Если по какой-то причине $HOME недоступен (пустой
|
||
// вывод), откатываемся на /tmp — доступно всегда, но переживает только
|
||
// текущую сессию ОС на некоторых системах (tmpfs с очисткой при
|
||
// перезагрузке), так что это осознанно резервный, а не основной путь.
|
||
func resolveBaseDir(conn *ssh.Conn) (string, error) {
|
||
res, err := conn.Run("echo $HOME")
|
||
if err != nil {
|
||
return "", fmt.Errorf("resolveBaseDir: %w", err)
|
||
}
|
||
home := strings.TrimSpace(res.Stdout)
|
||
if home == "" || home == "$HOME" {
|
||
return "/tmp/goherence-modules", nil
|
||
}
|
||
return home + "/.goherence/modules", nil
|
||
}
|
||
|
||
// remotePath — куда на хосте кладётся конкретная версия бинарника модуля.
|
||
// Версия зашита в путь, так что старые версии просто остаются на диске
|
||
// как есть (без отдельной чистки в первой версии) — это сознательно
|
||
// простое решение вместо retention-политики, которой пока никто не просил.
|
||
func remotePath(baseDir string, m *Module) string {
|
||
return fmt.Sprintf("%s/%s-%s", baseDir, m.Manifest.Name, m.Manifest.Version)
|
||
}
|
||
|
||
// ensureDeployed проверяет через маркер-файл, лежит ли на хосте уже
|
||
// актуальная версия модуля, и копирует бинарник только если нужно.
|
||
// Все шаги, которые могут завершиться ошибкой на удалённой стороне
|
||
// (mkdir, test), явно проверяют exit code — раньше здесь проверялась
|
||
// только транспортная ошибка, и permission denied на mkdir тихо
|
||
// протекал в WriteFile, где всплывал уже нечитаемым EOF.
|
||
func ensureDeployed(conn *ssh.Conn, m *Module) (string, error) {
|
||
arch, err := detectRemoteArch(conn)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
localBin, ok := m.BinPath[arch]
|
||
if !ok {
|
||
return "", fmt.Errorf("модуль %s: нет собранного бинарника под архитектуру %s "+
|
||
"(доступны: %v) — проверь кросс-компиляцию", m.Manifest.Name, arch, keysOf(m.BinPath))
|
||
}
|
||
|
||
baseDir, err := resolveBaseDir(conn)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
target := remotePath(baseDir, m)
|
||
|
||
res, err := conn.Run("test -x " + shQuote(target))
|
||
if err == nil && res.ExitCode == 0 {
|
||
return target, nil // нужная версия уже на хосте — ничего не копируем
|
||
}
|
||
|
||
data, err := os.ReadFile(localBin)
|
||
if err != nil {
|
||
return "", fmt.Errorf("читаю локальный бинарник %s: %w", localBin, err)
|
||
}
|
||
|
||
mkdirRes, err := conn.Run("mkdir -p " + shQuote(baseDir))
|
||
if err != nil {
|
||
return "", fmt.Errorf("mkdir -p %s: %w", baseDir, err)
|
||
}
|
||
if mkdirRes.ExitCode != 0 {
|
||
return "", fmt.Errorf("mkdir -p %s: exit %d: %s", baseDir, mkdirRes.ExitCode, strings.TrimSpace(mkdirRes.Stderr))
|
||
}
|
||
|
||
if err := conn.WriteFile(target, data, "0755"); err != nil {
|
||
return "", fmt.Errorf("доставка модуля %s на хост: %w", m.Manifest.Name, err)
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
func keysOf(m map[string]string) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func shQuote(s string) string {
|
||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||
}
|